diff --git a/.agents/skills/develop-opensecret-sdk/SKILL.md b/.agents/skills/develop-opensecret-sdk/SKILL.md index 9c87c4e8a..8571e02fd 100644 --- a/.agents/skills/develop-opensecret-sdk/SKILL.md +++ b/.agents/skills/develop-opensecret-sdk/SKILL.md @@ -1,6 +1,6 @@ --- name: develop-opensecret-sdk -description: Develop and review the Maple TypeScript/React and Rust SDKs under Maple's sdk directory. Use for SDK API, authentication, attestation, encrypted transport, tests, in-tree OpenSecret integration, package contents, versions, or an explicitly authorized npm or crates.io publishing handoff; use develop-maple for application-only work. +description: Develop and review the Maple TypeScript/React, Rust and Swift SDKs under Maple's sdk directory. Use for SDK API, authentication, attestation, encrypted transport, tests, in-tree OpenSecret integration, package contents, versions, or an explicitly authorized publishing handoff; use develop-maple for application-only work. --- # Develop the Maple SDK @@ -14,6 +14,9 @@ package boundaries remain independently versioned and publishable: - `src/` builds `@mapleai/sdk` for browser and React consumers. - `rust/` builds the `maple-sdk` crate (imported as `maple_sdk`) for native consumers. +- `swift/` builds the independent `MapleSDK` Swift package for native Apple + clients. Read `swift/AGENTS.md` and `swift/README.md`; its Xcode toolchain, + version, tests and publication boundary are independent of the other SDKs. - `apps/maple-research/frontend/package.json` is authoritative for whether Maple's browser client consumes a published TypeScript version or the in-tree `file:../../../sdk` package. - Research native clients, Maple Agent, and `proxy/` select their Rust SDK @@ -50,7 +53,28 @@ caller. ## Develop and validate -Use the SDK's pinned Nix shell. For TypeScript/React work: +Use the SDK's pinned Nix shell for TypeScript and Rust. Swift uses the root +Apple shell and host Xcode 26.5, including its bundled `swift-format`: + +```sh +nix develop --no-update-lock-file .#apple -c bash scripts/ci/sdk-swift.sh unit +nix develop --no-update-lock-file .#apple -c bash scripts/ci/sdk-swift.sh build +nix develop --no-update-lock-file .#apple -c bash scripts/ci/sdk-swift.sh ios-build +``` + +Run Swift commands from the repository root. Match `sdk-swift.yml` for +deterministic and disposable local-backend integration coverage. The +`integration` mode requires an explicit loopback endpoint and disposable +test account configuration; preserve externally owned services. Real +development-enclave trust checks use the separately authorized +`live-attestation` mode. Never infer hardware, provider or Nitro trust from +mocks. Keep one shared credential authority per API origin and test atomic +replacement, cancellation, stale generations and persistence failures. +Swift package-root distribution has not been published; local source, +source checkpoint tags and an eventual independent package publication must +not create or advance a Maple app release. See `swift/README.md`. + +For TypeScript/React work: ```sh nix develop --no-update-lock-file -c bun install --frozen-lockfile --ignore-scripts diff --git a/.agents/skills/validate-maple/SKILL.md b/.agents/skills/validate-maple/SKILL.md index 78849b8f5..1eb9a7bc6 100644 --- a/.agents/skills/validate-maple/SKILL.md +++ b/.agents/skills/validate-maple/SKILL.md @@ -105,6 +105,30 @@ Use for iOS, Android, signing, updater metadata, installers, entitlements, or di Run commands from the repository root unless the command changes directory explicitly. +### Native SwiftUI iOS app + +Read `apps/maple-ios/AGENTS.md`. The app uses the root Apple shell and host +Xcode 26.5; its PR profile ignores local overrides and builds with the +development bundle identity. Run deterministic checks and unsigned packaging: + +```bash +nix develop --no-update-lock-file .#apple -c bash scripts/ci/ios-native.sh unit +nix develop --no-update-lock-file .#apple -c bash scripts/ci/ios-native.sh cross-build +nix develop --no-update-lock-file .#apple -c bash scripts/ci/ios-native.sh simulator-build +nix develop --no-update-lock-file .#apple -c bash scripts/ci/ios-native.sh device-build +``` + +The component hook runs only the first command. These commands clear inherited +live-test switches and never sign or upload. Swift package tests on macOS are +core-workflow evidence; iOS compilation is not a simulator UI pass. Run the +exact configured app on iPhone and iPad simulators for UI, lifecycle, resource, +accessibility, account isolation and local-service flows. Record the built +bundle's identity and public origins, simulator runtime and test scope. +Keep real-device permissions, hardware, protection and distribution evidence +distinct. Use only explicitly authorized disposable fixtures for live account +tests; credentials belong in a private child environment, never command +arguments, screenshots, tracked files or logs. + ### Standalone hosted Auth For changes confined to `apps/maple-auth`, use its own guide and checks: diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 5e7e51c5c..c93703669 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -119,6 +119,12 @@ fi if hook_selected sdk_rust || hook_selected sdk_ts; then run_component sdk "$REPO_ROOT/sdk" "." "$REPO_ROOT/sdk/.githooks/pre-commit" fi +if hook_selected sdk_swift; then + run_component sdk-swift "$REPO_ROOT" ".#apple" "$REPO_ROOT/sdk/swift/.githooks/pre-commit" +fi +if hook_selected ios_native; then + run_component maple-ios "$REPO_ROOT" ".#apple" "$REPO_ROOT/apps/maple-ios/.githooks/pre-commit" +fi if hook_selected proxy; then run_component proxy "$REPO_ROOT/proxy" "." "$REPO_ROOT/proxy/.githooks/pre-commit" fi diff --git a/.github/workflows/ios-native.yml b/.github/workflows/ios-native.yml new file mode 100644 index 000000000..ecc9fc8f1 --- /dev/null +++ b/.github/workflows/ios-native.yml @@ -0,0 +1,91 @@ +name: Native iOS CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ios-native-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + changes: + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + ios_native: ${{ steps.classify.outputs.ios_native }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + persist-credentials: false + - name: Select native app inputs + id: classify + env: + BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} + HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + run: | + set -euo pipefail + enable() { echo 'ios_native=true' >> "$GITHUB_OUTPUT"; } + case "$GITHUB_EVENT_NAME" in + pull_request) separator='...' ;; + push) + separator='..' + if [[ "$BASE_SHA" =~ ^0+$ ]]; then enable; exit 0; fi + ;; + *) enable; exit 0 ;; + esac + if ! git cat-file -e "${BASE_SHA}^{commit}" || ! git cat-file -e "${HEAD_SHA}^{commit}"; then + enable; exit 0 + fi + changed_paths="$(mktemp)" + trap 'rm -f "$changed_paths"' EXIT + if ! git diff --no-renames --name-only -z "${BASE_SHA}${separator}${HEAD_SHA}" > "$changed_paths"; then + enable; exit 0 + fi + python3 scripts/ci/ios_native_change_detection.py < "$changed_paths" >> "$GITHUB_OUTPUT" + + ios-native: + needs: changes + if: ${{ always() && !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.ios_native != 'false') }} + runs-on: macos-26-xlarge + timeout-minutes: 60 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - name: Select Xcode 26.5 + run: | + set -euo pipefail + for xcode_app in \ + /Applications/Xcode_26.5.app \ + /Applications/Xcode_26.5.0.app \ + /Applications/Xcode_26.5_beta_2.app \ + /Applications/Xcode_26.5_beta.app + do + if [ -x "${xcode_app}/Contents/Developer/usr/bin/xcodebuild" ]; then + sudo xcode-select -s "${xcode_app}/Contents/Developer" + xcodebuild -version + exit 0 + fi + done + echo "Xcode 26.5 is not installed on this runner." >&2 + exit 1 + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22 + with: + github-token: "" + - name: Check formatting and deterministic app tests + run: nix develop --no-update-lock-file .#apple -c bash scripts/ci/ios-native.sh unit + - name: Compile iOS device and simulator packages + run: nix develop --no-update-lock-file .#apple -c bash scripts/ci/ios-native.sh cross-build + - name: Build unsigned fixed-profile simulator app + run: nix develop --no-update-lock-file .#apple -c bash scripts/ci/ios-native.sh simulator-build + - name: Build unsigned fixed-profile device app + run: nix develop --no-update-lock-file .#apple -c bash scripts/ci/ios-native.sh device-build diff --git a/.github/workflows/sdk-swift.yml b/.github/workflows/sdk-swift.yml new file mode 100644 index 000000000..53315e489 --- /dev/null +++ b/.github/workflows/sdk-swift.yml @@ -0,0 +1,103 @@ +name: SDK Swift CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: sdk-swift-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + changes: + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + sdk_swift: ${{ steps.classify.outputs.sdk_swift }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + persist-credentials: false + - name: Select SDK and backend contract changes + id: classify + env: + BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} + HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + run: | + set -euo pipefail + enable() { echo 'sdk_swift=true' >> "$GITHUB_OUTPUT"; } + case "$GITHUB_EVENT_NAME" in + pull_request) separator='...' ;; + push) + separator='..' + if [[ "$BASE_SHA" =~ ^0+$ ]]; then enable; exit 0; fi + ;; + *) enable; exit 0 ;; + esac + if ! git cat-file -e "${BASE_SHA}^{commit}" || ! git cat-file -e "${HEAD_SHA}^{commit}"; then + enable; exit 0 + fi + changed_paths="$(mktemp)" + trap 'rm -f "$changed_paths"' EXIT + if ! git diff --no-renames --name-only -z "${BASE_SHA}${separator}${HEAD_SHA}" > "$changed_paths"; then + enable; exit 0 + fi + python3 scripts/ci/hook_change_detection.py < "$changed_paths" > "$changed_paths.hook" + python3 scripts/ci/opensecret_change_detection.py < "$changed_paths" > "$changed_paths.backend" + if grep -qx 'sdk_swift=true' "$changed_paths.hook" || grep -qx 'integration=true' "$changed_paths.backend"; then + enable + else + echo 'sdk_swift=false' >> "$GITHUB_OUTPUT" + fi + + sdk-swift: + needs: changes + if: ${{ always() && !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.sdk_swift != 'false') }} + runs-on: macos-26-xlarge + timeout-minutes: 60 + env: + OPENSECRET_DEV_POSTGRES: "0" + OPENSECRET_DEV_ENV: "0" + OPENSECRET_DEV_CONTAINERS: "0" + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + persist-credentials: false + submodules: recursive + - name: Select Xcode 26.5 + run: | + set -euo pipefail + for xcode_app in \ + /Applications/Xcode_26.5.app \ + /Applications/Xcode_26.5.0.app \ + /Applications/Xcode_26.5_beta_2.app \ + /Applications/Xcode_26.5_beta.app + do + if [ -x "${xcode_app}/Contents/Developer/usr/bin/xcodebuild" ]; then + sudo xcode-select -s "${xcode_app}/Contents/Developer" + xcodebuild -version + exit 0 + fi + done + echo "Xcode 26.5 is not installed on this runner." >&2 + exit 1 + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22 + with: + github-token: "" + - name: Check formatting and deterministic Swift tests + run: nix develop --no-update-lock-file .#apple -c bash scripts/ci/sdk-swift.sh unit + - name: Build the independent Swift package + run: nix develop --no-update-lock-file .#apple -c bash scripts/ci/sdk-swift.sh build + - name: Compile the iOS device and simulator library + run: nix develop --no-update-lock-file .#apple -c bash scripts/ci/sdk-swift.sh ios-build + - name: Verify Swift against disposable in-tree OpenSecret + run: nix develop --no-update-lock-file './services/opensecret?submodules=1' -c bash scripts/ci/sdk-swift-backend.sh diff --git a/AGENTS.md b/AGENTS.md index c597ab740..e2aab791b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,8 +16,13 @@ current source and tests take precedence over historical design documents. - `apps/maple-agent/`: GPUI desktop-v2 prototype, ACP and proxy CLI. Read its [guide](apps/maple-agent/AGENTS.md) and `$develop-maple-agent`. Its runtime and update discovery are separate from Research and its existing Agent Mode. -- `sdk/`: TypeScript/React and Rust Maple SDKs for the OpenSecret backend. Read +- `apps/maple-ios/`: native SwiftUI app for iPhone and iPad, with an independent + unsigned validation lane and development identity. Read its + [guide](apps/maple-ios/AGENTS.md); its app core consumes `sdk/swift/`. +- `sdk/`: TypeScript/React, Rust and Swift Maple SDKs for the OpenSecret backend. Read `$develop-opensecret-sdk` and the SDK documentation. + The native Apple package under `sdk/swift/` has its own contributor guide, + Xcode-based checks and independent version; it does not use the SDK Rust shell. - `proxy/`: the separate OpenAI-compatible relay, also consumed by the desktop app. Read `$develop-maple-proxy` and the proxy documentation. - `services/updates/`: the updater Worker. Preserve its deployed identity, @@ -121,6 +126,15 @@ manifest selects, so changing that manifest (a pin bump or a local link) is what selects its lanes. Tests, docs, container-only inputs, and standalone component lockfiles retain their independent lanes. Update the classifier and its table-driven tests when the dependency graph or component layout changes. + +Native iOS has its own `ios_native` hook component in the root `.#apple` shell +and `ios-native.yml` workflow. Its change selector includes the app's explicit +font inputs and root toolchain but does not select packaging for SDK-only +edits. Native app changes do not select Research packaging. The hook runs +format lint and deterministic package tests; CI also compiles the package for +both iOS targets and builds unsigned PR-profile apps. Runtime fixtures and +physical-device checks remain separate evidence. + The backend has its own root `opensecret-ci.yml` workflow and change selector; `sdk-integration.yml` tests both SDKs against `services/opensecret/` from the same checkout. Backend changes do not imply Research or Agent packaging. diff --git a/apps/maple-ios/.githooks/pre-commit b/apps/maple-ios/.githooks/pre-commit new file mode 100755 index 000000000..be7be66b4 --- /dev/null +++ b/apps/maple-ios/.githooks/pre-commit @@ -0,0 +1,7 @@ +#!/bin/sh +# The root orchestrator supplies the pinned Apple shell. No live test opt-ins, +# service startup, signing or app packaging belong in this local hook. +set -u +. "${MAPLE_HOOK_REPO_ROOT:?}/.githooks/lib/common.sh" +hook_require xcrun +hook_run bash "$MAPLE_HOOK_REPO_ROOT/scripts/ci/ios-native.sh" unit diff --git a/apps/maple-ios/.gitignore b/apps/maple-ios/.gitignore new file mode 100644 index 000000000..1af3f9d3d --- /dev/null +++ b/apps/maple-ios/.gitignore @@ -0,0 +1,5 @@ +.build/ +.swiftpm/ +Config/Local.generated.xcconfig +*.xcuserstate +xcuserdata/ diff --git a/apps/maple-ios/.swift-format b/apps/maple-ios/.swift-format new file mode 100644 index 000000000..4fa879e53 --- /dev/null +++ b/apps/maple-ios/.swift-format @@ -0,0 +1,9 @@ +{ + "version": 1, + "lineLength": 120, + "indentation": { "spaces": 4 }, + "rules": { + "AlwaysUseLowerCamelCase": false, + "UseTripleSlashForDocumentationComments": true + } +} diff --git a/apps/maple-ios/AGENTS.md b/apps/maple-ios/AGENTS.md new file mode 100644 index 000000000..ba27cb649 --- /dev/null +++ b/apps/maple-ios/AGENTS.md @@ -0,0 +1,89 @@ +# Maple native iOS + +Read the monorepo [guide](../../AGENTS.md) first. This component is the native +SwiftUI application for iPhone and iPad, with iOS 26 as its minimum. The +development bundle and callback scheme are `cloud.opensecret.maple.native.dev`. +The icon name is `Maple Native Dev`. Preserve its separate Keychain service and +app container; do not reuse Research identities or import another app's state. +Production identity, provisioning, distribution and uploads are separate work. + +## Ownership + +- `App/` is the thin iOS application entry point. +- `Sources/MapleAppCore/` owns testable account workflows and injected service, + persistence, clock and identifier boundaries. It must not depend on SwiftUI. +- `Sources/MapleAppUI/` owns SwiftUI presentation and narrowly justified UIKit + adapters. Guard iOS-only files so the core package can be tested on macOS. +- `Tests/MapleAppCoreTests/` tests behavior and ownership transitions. +- `Maple.xcodeproj/` is checked-in source, not generated output. It uses the + local Swift package in this directory; that package links `../../sdk/swift`. +- `Config/`, `Resources/` and `scripts/` own application identity, explicit + build profiles, bundled assets and repeatable unsigned builds. + +Use the Swift SDK for all OpenSecret requests. Do not duplicate transport, +attestation, credential renewal or server authorization in the app. Billing +and flags are independent HTTP API boundaries; never depend on their server +source trees. Account identity and a revocable generation must fence every +asynchronous result and private storage write, including the same account +signing in again. + +Use Observation and Swift concurrency, with main-actor UI models and one owner +for each mutable state. Ongoing chat, queue and playback tasks belong to the +account session independently of navigation. Keep CPU-heavy work off the UI +actor. Local drafts and queue storage require Complete Protection, excluded +backups and account cleanup; microphone audio remains in memory. + +## Configuration and privacy + +Preserve externally managed environment files. `scripts/configure-local.py` +reads only three allowlisted public endpoint values from an explicit env file, +plus explicit public client ID and auth origin arguments. It writes the +ignored `Config/Local.generated.xcconfig`; it never reads account credentials +or sources the env file. Local endpoints must be explicit HTTP loopback +origins. The PR profile is fixed and does not include that local file. + +Keep credentials in the SDK secure store. Never log tokens, raw deep links, +private messages, decrypted responses, raw audio or credential-bearing URLs. +Treat renderer input, files, URL callbacks and persisted data as untrusted. +Use `$review-maple-security` for changes to these boundaries and +`$validate-maple` for simulator/application evidence. + +Reuse the existing repository font files through the declared build inputs. +Do not convert or modify Array, or add another raw Array copy to this public +component. Keep font notices in the bundled `FontLicenses` directory. + +## Checks + +Run from the Maple root in the pinned `.#apple` shell with host Xcode 26.5: + +```sh +nix develop --no-update-lock-file .#apple -c apps/maple-ios/scripts/build.sh unit +nix develop --no-update-lock-file .#apple -c apps/maple-ios/scripts/build.sh build pr +``` + +Use `build local` for a deliberately configured loopback stack. `build release` +is an optimized unsigned development artifact using those local inputs; it is +not a production or store lane. Neither build command launches the app. + +For simulator installation and UI tests, use `runtime-build local` or +`test-simulator local` with an explicit `MAPLE_IOS_SIMULATOR_ID`, as documented +in the [README](README.md#build-and-test). These modes use Xcode's ad-hoc +simulator identity for Keychain access and debug package compilation; they need +no developer team, certificate or provisioning profile. Keep the unsigned PR +compile lane separate from runtime evidence. + +The pinned Textual runtime in `Vendor/textual` has documented Copy export +patches and per-file provenance. Follow [its patch notes](Vendor/textual/MAPLE-PATCH.md) +when changing it; do not reformat the vendored runtime or patch SwiftPM checkouts. + +For a runtime claim, record the exact bundle path, identifier and compiled +public endpoint profile, simulator UDID/runtime, test scope and observed +behavior. Test on iPhone and a resizable single iPad window. Complete the +simulator portion before handing real device boundaries off separately. Do +not install global substitute tools, use production signing, upload, deploy or repurpose the +existing Research release lanes during component development. + +Keep app-owned strings in catalogs with context, including accessibility and +permission strings. English is the launch language; test expanded strings, +RTL, Dynamic Type, VoiceOver semantics and multilingual fallback. No language +picker, telemetry, purchasing, new guest signup or Mac app target belongs here. diff --git a/apps/maple-ios/App/MapleApp.swift b/apps/maple-ios/App/MapleApp.swift new file mode 100644 index 000000000..b90e70588 --- /dev/null +++ b/apps/maple-ios/App/MapleApp.swift @@ -0,0 +1,11 @@ +import MapleAppUI +import SwiftUI + +@main +struct MapleApp: App { + var body: some Scene { + WindowGroup { + MapleRootView() + } + } +} diff --git a/apps/maple-ios/Config/Base.xcconfig b/apps/maple-ios/Config/Base.xcconfig new file mode 100644 index 000000000..745d9806c --- /dev/null +++ b/apps/maple-ios/Config/Base.xcconfig @@ -0,0 +1,23 @@ +PRODUCT_NAME = Maple +PRODUCT_BUNDLE_IDENTIFIER = cloud.opensecret.maple.native.dev +MARKETING_VERSION = 0.1.0 +CURRENT_PROJECT_VERSION = 8 +SWIFT_VERSION = 6.0 +SWIFT_STRICT_CONCURRENCY = complete +SWIFT_TREAT_WARNINGS_AS_ERRORS = YES +CLANG_ENABLE_MODULES = YES +ENABLE_USER_SCRIPT_SANDBOXING = YES +IPHONEOS_DEPLOYMENT_TARGET = 26.0 +TARGETED_DEVICE_FAMILY = 1,2 +SUPPORTED_PLATFORMS = iphoneos iphonesimulator +SUPPORTS_MACCATALYST = NO +SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO +SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO +GENERATE_INFOPLIST_FILE = NO +INFOPLIST_FILE = Resources/Info.plist +ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon +ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor +SWIFT_EMIT_LOC_STRINGS = YES +LOCALIZATION_PREFERS_STRING_CATALOGS = YES +CODE_SIGN_STYLE = Automatic +MAPLE_CALLBACK_SCHEME = cloud.opensecret.maple.native.dev diff --git a/apps/maple-ios/Config/Local.xcconfig b/apps/maple-ios/Config/Local.xcconfig new file mode 100644 index 000000000..31127397e --- /dev/null +++ b/apps/maple-ios/Config/Local.xcconfig @@ -0,0 +1,4 @@ +#include "Base.xcconfig" +#include? "Local.generated.xcconfig" +SWIFT_OPTIMIZATION_LEVEL = -Onone +SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) DEBUG diff --git a/apps/maple-ios/Config/PR.xcconfig b/apps/maple-ios/Config/PR.xcconfig new file mode 100644 index 000000000..de35a6200 --- /dev/null +++ b/apps/maple-ios/Config/PR.xcconfig @@ -0,0 +1,10 @@ +#include "Base.xcconfig" +// Fixed public development profile. Never include developer-local configuration. +MAPLE_PROFILE = development +MAPLE_API_URL = https:/$()/enclave.secretgpt.ai +MAPLE_BILLING_URL = https:/$()/billing-dev.opensecret.cloud +MAPLE_FLAGS_URL = https:/$()/flags-dev.opensecret.cloud +MAPLE_AUTH_URL = https:/$()/app-dev.trymaple.ai +MAPLE_CLIENT_ID = ba5a14b5-d915-47b1-b7b1-afda52bc5fc6 +SWIFT_OPTIMIZATION_LEVEL = -Onone +SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) DEBUG diff --git a/apps/maple-ios/Config/Release.xcconfig b/apps/maple-ios/Config/Release.xcconfig new file mode 100644 index 000000000..c62ef4a5f --- /dev/null +++ b/apps/maple-ios/Config/Release.xcconfig @@ -0,0 +1,5 @@ +// Optimized development artifact only; production identity and distribution are separate. +#include "Base.xcconfig" +#include? "Local.generated.xcconfig" +SWIFT_OPTIMIZATION_LEVEL = -O +SWIFT_COMPILATION_MODE = wholemodule diff --git a/apps/maple-ios/Maple.xcodeproj/project.pbxproj b/apps/maple-ios/Maple.xcodeproj/project.pbxproj new file mode 100644 index 000000000..20968b25c --- /dev/null +++ b/apps/maple-ios/Maple.xcodeproj/project.pbxproj @@ -0,0 +1,138 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = {}; + objectVersion = 60; + objects = { + 000000000000000000000240 = {isa = PBXBuildFile; fileRef = 000000000000000000000241; }; + 000000000000000000000241 = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; + 000000000000000000000220 = {isa = PBXBuildFile; fileRef = 000000000000000000000221; }; + 000000000000000000000221 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuChatUI.swift; sourceTree = ""; }; + 000000000000000000000210 = {isa = PBXBuildFile; fileRef = 000000000000000000000211; }; + 000000000000000000000211 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferenceComparisonUITests.swift; sourceTree = ""; }; + 000000000000000000000200 = {isa = PBXBuildFile; fileRef = 000000000000000000000201; }; + 000000000000000000000201 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationUITests.swift; sourceTree = ""; }; + 000000000000000000000190 = {isa = PBXBuildFile; fileRef = 000000000000000000000191; }; + 000000000000000000000191 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootListeningUITests.swift; sourceTree = ""; }; + 000000000000000000000001 = {isa = PBXBuildFile; fileRef = 000000000000000000000011; }; + 000000000000000000000002 = {isa = PBXBuildFile; fileRef = 000000000000000000000012; }; + 000000000000000000000003 = {isa = PBXBuildFile; fileRef = 000000000000000000000013; }; + 000000000000000000000004 = {isa = PBXBuildFile; fileRef = 000000000000000000000014; }; + 000000000000000000000005 = {isa = PBXBuildFile; fileRef = 000000000000000000000015; }; + 000000000000000000000006 = {isa = PBXBuildFile; productRef = 000000000000000000000081; }; + 000000000000000000000011 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapleApp.swift; sourceTree = ""; }; + 000000000000000000000012 = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 000000000000000000000013 = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; + 000000000000000000000014 = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + 000000000000000000000015 = {isa = PBXFileReference; lastKnownFileType = folder; path = FontLicenses; sourceTree = ""; }; + 000000000000000000000016 = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 000000000000000000000017 = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Base.xcconfig; sourceTree = ""; }; + 000000000000000000000018 = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Local.xcconfig; sourceTree = ""; }; + 000000000000000000000019 = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = PR.xcconfig; sourceTree = ""; }; + 00000000000000000000001A = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 00000000000000000000001B = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Maple.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 000000000000000000000020 = {isa = PBXGroup; children = (000000000000000000000021, 000000000000000000000022, 000000000000000000000023, 000000000000000000000024, 000000000000000000000093, 0000000000000000000000A1); sourceTree = ""; }; + 000000000000000000000021 = {isa = PBXGroup; children = (000000000000000000000011); path = App; sourceTree = ""; }; + 000000000000000000000022 = {isa = PBXGroup; children = (000000000000000000000241, 000000000000000000000012, 000000000000000000000013, 000000000000000000000014, 000000000000000000000015, 000000000000000000000016); path = Resources; sourceTree = ""; }; + 000000000000000000000023 = {isa = PBXGroup; children = (000000000000000000000017, 000000000000000000000018, 000000000000000000000019, 00000000000000000000001A); path = Config; sourceTree = ""; }; + 000000000000000000000024 = {isa = PBXGroup; children = (00000000000000000000001B, 000000000000000000000092); name = Products; sourceTree = ""; }; + 000000000000000000000030 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (000000000000000000000001); runOnlyForDeploymentPostprocessing = 0; }; + 000000000000000000000031 = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (000000000000000000000006); runOnlyForDeploymentPostprocessing = 0; }; + 000000000000000000000032 = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (000000000000000000000240, 000000000000000000000002, 000000000000000000000003, 000000000000000000000004, 000000000000000000000005, 0000000000000000000000A0); runOnlyForDeploymentPostprocessing = 0; }; + 000000000000000000000033 = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = (); + inputPaths = ("$(SRCROOT)/../maple-agent/app/assets/fonts/Array-Regular.otf", "$(SRCROOT)/../maple-research/frontend/public/fonts/Manrope-VariableFont_wght.ttf", "$(SRCROOT)/Sources/MapleAppUI/Resources/Fonts/GeistMono-Regular.ttf", "$(SRCROOT)/scripts/stage-fonts.sh"); + name = "Bundle licensed native fonts"; + outputPaths = ("$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/Array-Regular.otf", "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/Manrope-VariableFont_wght.ttf", "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/GeistMono-Regular.ttf"); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$SRCROOT/scripts/stage-fonts.sh\"\n"; + }; + 000000000000000000000040 = { + isa = PBXNativeTarget; + buildConfigurationList = 000000000000000000000071; + buildPhases = (000000000000000000000030, 000000000000000000000031, 000000000000000000000032, 000000000000000000000033); + buildRules = (); + dependencies = (); + name = Maple; + packageProductDependencies = (000000000000000000000081); + productName = Maple; + productReference = 00000000000000000000001B; + productType = "com.apple.product-type.application"; + }; + 000000000000000000000050 = { + isa = PBXProject; + attributes = {BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 2650; LastUpgradeCheck = 2650; TargetAttributes = {000000000000000000000040 = {CreatedOnToolsVersion = 26.5; }; }; }; + buildConfigurationList = 000000000000000000000070; + compatibilityVersion = "Xcode 15.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = (en, Base); + mainGroup = 000000000000000000000020; + packageReferences = (000000000000000000000080); + productRefGroup = 000000000000000000000024; + projectDirPath = ""; + projectRoot = ""; + targets = (000000000000000000000040, 000000000000000000000098); + }; + 000000000000000000000060 = {isa = XCBuildConfiguration; buildSettings = {SDKROOT = iphoneos; }; name = Local; }; + 000000000000000000000061 = {isa = XCBuildConfiguration; buildSettings = {SDKROOT = iphoneos; }; name = PR; }; + 000000000000000000000062 = {isa = XCBuildConfiguration; buildSettings = {SDKROOT = iphoneos; }; name = Release; }; + 000000000000000000000063 = {isa = XCBuildConfiguration; baseConfigurationReference = 000000000000000000000018; buildSettings = {}; name = Local; }; + 000000000000000000000064 = {isa = XCBuildConfiguration; baseConfigurationReference = 000000000000000000000019; buildSettings = {}; name = PR; }; + 000000000000000000000065 = {isa = XCBuildConfiguration; baseConfigurationReference = 00000000000000000000001A; buildSettings = {}; name = Release; }; + 000000000000000000000070 = {isa = XCConfigurationList; buildConfigurations = (000000000000000000000060, 000000000000000000000061, 000000000000000000000062); defaultConfigurationIsVisible = 0; defaultConfigurationName = Local; }; + 000000000000000000000071 = {isa = XCConfigurationList; buildConfigurations = (000000000000000000000063, 000000000000000000000064, 000000000000000000000065); defaultConfigurationIsVisible = 0; defaultConfigurationName = Local; }; + 000000000000000000000080 = {isa = XCLocalSwiftPackageReference; relativePath = .; }; + 000000000000000000000081 = {isa = XCSwiftPackageProductDependency; package = 000000000000000000000080; productName = MapleAppUI; }; + 000000000000000000000090 = {isa = PBXBuildFile; fileRef = 000000000000000000000091; }; + 000000000000000000000091 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapleUITests.swift; sourceTree = ""; }; + 000000000000000000000092 = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MapleUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 000000000000000000000093 = {isa = PBXGroup; children = (000000000000000000000231, 000000000000000000000221, 000000000000000000000091, 0000000000000000000000B1, 0000000000000000000000C1, 0000000000000000000000D1, 0000000000000000000000E1, 0000000000000000000000F1, 000000000000000000000101, 000000000000000000000111, 000000000000000000000121, 000000000000000000000131, 000000000000000000000141, 000000000000000000000151, 000000000000000000000161, 000000000000000000000171, 000000000000000000000181, 000000000000000000000191, 000000000000000000000201, 000000000000000000000211); path = UITests; sourceTree = ""; }; + 000000000000000000000094 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (000000000000000000000230, 000000000000000000000220, 000000000000000000000090, 0000000000000000000000B0, 0000000000000000000000C0, 0000000000000000000000D0, 0000000000000000000000E0, 0000000000000000000000F0, 000000000000000000000100, 000000000000000000000110, 000000000000000000000120, 000000000000000000000130, 000000000000000000000140, 000000000000000000000150, 000000000000000000000160, 000000000000000000000170, 000000000000000000000180, 000000000000000000000190, 000000000000000000000200, 000000000000000000000210); runOnlyForDeploymentPostprocessing = 0; }; + 000000000000000000000095 = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; }; + 000000000000000000000096 = {isa = PBXContainerItemProxy; containerPortal = 000000000000000000000050; proxyType = 1; remoteGlobalIDString = 000000000000000000000040; remoteInfo = Maple; }; + 000000000000000000000097 = {isa = PBXTargetDependency; target = 000000000000000000000040; targetProxy = 000000000000000000000096; }; + 000000000000000000000098 = {isa = PBXNativeTarget; buildConfigurationList = 000000000000000000000099; buildPhases = (000000000000000000000094, 000000000000000000000095); buildRules = (); dependencies = (000000000000000000000097); name = MapleUITests; productName = MapleUITests; productReference = 000000000000000000000092; productType = "com.apple.product-type.bundle.ui-testing"; }; + 000000000000000000000099 = {isa = XCConfigurationList; buildConfigurations = (00000000000000000000009A, 00000000000000000000009B, 00000000000000000000009C); defaultConfigurationIsVisible = 0; defaultConfigurationName = Local; }; + 00000000000000000000009A = {isa = XCBuildConfiguration; buildSettings = {ALWAYS_SEARCH_USER_PATHS = NO; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; PRODUCT_BUNDLE_IDENTIFIER = cloud.opensecret.maple.native.dev.uitests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 6.0; SWIFT_STRICT_CONCURRENCY = complete; SWIFT_TREAT_WARNINGS_AS_ERRORS = YES; TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = Maple; CODE_SIGNING_ALLOWED = NO; }; name = Local; }; + 00000000000000000000009B = {isa = XCBuildConfiguration; buildSettings = {ALWAYS_SEARCH_USER_PATHS = NO; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; PRODUCT_BUNDLE_IDENTIFIER = cloud.opensecret.maple.native.dev.uitests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 6.0; SWIFT_STRICT_CONCURRENCY = complete; SWIFT_TREAT_WARNINGS_AS_ERRORS = YES; TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = Maple; CODE_SIGNING_ALLOWED = NO; }; name = PR; }; + 00000000000000000000009C = {isa = XCBuildConfiguration; buildSettings = {ALWAYS_SEARCH_USER_PATHS = NO; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; PRODUCT_BUNDLE_IDENTIFIER = cloud.opensecret.maple.native.dev.uitests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 6.0; SWIFT_STRICT_CONCURRENCY = complete; SWIFT_TREAT_WARNINGS_AS_ERRORS = YES; TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = Maple; CODE_SIGNING_ALLOWED = NO; }; name = Release; }; + + 0000000000000000000000A0 = {isa = PBXBuildFile; fileRef = 0000000000000000000000A1; }; + 0000000000000000000000A1 = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Sources/MapleAppUI/Resources/Localizable.xcstrings; sourceTree = ""; }; + 0000000000000000000000B0 = {isa = PBXBuildFile; fileRef = 0000000000000000000000B1; }; + 0000000000000000000000B1 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RendererUITests.swift; sourceTree = ""; }; + 0000000000000000000000C0 = {isa = PBXBuildFile; fileRef = 0000000000000000000000C1; }; + 0000000000000000000000C1 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictationUITests.swift; sourceTree = ""; }; + 0000000000000000000000D0 = {isa = PBXBuildFile; fileRef = 0000000000000000000000D1; }; + 0000000000000000000000D1 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthWorkflowUITests.swift; sourceTree = ""; }; + 0000000000000000000000E0 = {isa = PBXBuildFile; fileRef = 0000000000000000000000E1; }; + 0000000000000000000000E1 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntitlementUITests.swift; sourceTree = ""; }; + 0000000000000000000000F0 = {isa = PBXBuildFile; fileRef = 0000000000000000000000F1; }; + 0000000000000000000000F1 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserUITests.swift; sourceTree = ""; }; + 000000000000000000000100 = {isa = PBXBuildFile; fileRef = 000000000000000000000101; }; + 000000000000000000000101 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryWorkflowUITests.swift; sourceTree = ""; }; + 000000000000000000000110 = {isa = PBXBuildFile; fileRef = 000000000000000000000111; }; + 000000000000000000000111 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatWorkflowUITests.swift; sourceTree = ""; }; + 000000000000000000000120 = {isa = PBXBuildFile; fileRef = 000000000000000000000121; }; + 000000000000000000000121 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootLifecycleUITests.swift; sourceTree = ""; }; + 000000000000000000000130 = {isa = PBXBuildFile; fileRef = 000000000000000000000131; }; + 000000000000000000000131 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryControlsUITests.swift; sourceTree = ""; }; + 000000000000000000000140 = {isa = PBXBuildFile; fileRef = 000000000000000000000141; }; + 000000000000000000000141 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentationCoverUITests.swift; sourceTree = ""; }; + 000000000000000000000150 = {isa = PBXBuildFile; fileRef = 000000000000000000000151; }; + 000000000000000000000151 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTextEntry.swift; sourceTree = ""; }; + 000000000000000000000160 = {isa = PBXBuildFile; fileRef = 000000000000000000000161; }; + 000000000000000000000161 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountLinkUITests.swift; sourceTree = ""; }; + 000000000000000000000170 = {isa = PBXBuildFile; fileRef = 000000000000000000000171; }; + 000000000000000000000171 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplyPlayerUITests.swift; sourceTree = ""; }; + 000000000000000000000180 = {isa = PBXBuildFile; fileRef = 000000000000000000000181; }; + 000000000000000000000181 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingViewportUITests.swift; sourceTree = ""; }; + 000000000000000000000230 = {isa = PBXBuildFile; fileRef = 000000000000000000000231; }; + 000000000000000000000231 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatOptionsUITests.swift; sourceTree = ""; }; + }; + rootObject = 000000000000000000000050; +} diff --git a/apps/maple-ios/Maple.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apps/maple-ios/Maple.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 000000000..e2e72aea2 --- /dev/null +++ b/apps/maple-ios/Maple.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,30 @@ +{ + "originHash" : "3ab43c8d237d6b9afdc52889d305d2853f9499f25884f55c9e72279cb49a358e", + "pins" : [ + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras.git", + "state" : { + "revision" : "82a4ae7170d98d8538ec77238b7eb8e7199ef2e8" + } + }, + { + "identity" : "swiftui-math", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/swiftui-math.git", + "state" : { + "revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71" + } + }, + { + "identity" : "textual", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/textual.git", + "state" : { + "revision" : "01b51875a5406eefc95f52a058cb059e7bc94dc4" + } + } + ], + "version" : 3 +} diff --git a/apps/maple-ios/Maple.xcodeproj/xcshareddata/xcschemes/Maple.xcscheme b/apps/maple-ios/Maple.xcodeproj/xcshareddata/xcschemes/Maple.xcscheme new file mode 100644 index 000000000..9867718ee --- /dev/null +++ b/apps/maple-ios/Maple.xcodeproj/xcshareddata/xcschemes/Maple.xcscheme @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/maple-ios/Package.resolved b/apps/maple-ios/Package.resolved new file mode 100644 index 000000000..c942d3fa7 --- /dev/null +++ b/apps/maple-ios/Package.resolved @@ -0,0 +1,22 @@ +{ + "originHash" : "9072ff129f6d253d50b7a85d57e98fb00a28479845217cab4a0448321131d9d3", + "pins" : [ + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras.git", + "state" : { + "revision" : "82a4ae7170d98d8538ec77238b7eb8e7199ef2e8" + } + }, + { + "identity" : "swiftui-math", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/swiftui-math.git", + "state" : { + "revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71" + } + } + ], + "version" : 3 +} diff --git a/apps/maple-ios/Package.swift b/apps/maple-ios/Package.swift new file mode 100644 index 000000000..ffa37ef69 --- /dev/null +++ b/apps/maple-ios/Package.swift @@ -0,0 +1,51 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "MapleApp", + defaultLocalization: "en", + platforms: [.iOS("26.0"), .macOS(.v15)], + products: [ + .library(name: "MapleAppCore", targets: ["MapleAppCore"]), + .library(name: "MapleAppUI", targets: ["MapleAppUI"]), + ], + dependencies: [ + .package(path: "../../sdk/swift"), + .package(path: "Vendor/textual"), + .package( + url: "https://github.com/gonzalezreal/swiftui-math.git", + revision: "0b5c2cfaaec8d6193db206f675048eeb5ce95f71"), + .package( + url: "https://github.com/pointfreeco/swift-concurrency-extras.git", + revision: "82a4ae7170d98d8538ec77238b7eb8e7199ef2e8" + ), + ], + targets: [ + .target(name: "MapleAppCore", dependencies: [.product(name: "MapleSDK", package: "swift")]), + .target( + name: "MapleAppUI", + dependencies: [ + "MapleAppCore", .product(name: "MapleSDK", package: "swift"), + .product(name: "Textual", package: "textual"), + .product(name: "SwiftUIMath", package: "swiftui-math"), + .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), + ], + resources: [ + .process("Resources/Localizable.xcstrings"), .process("Resources/Auth.xcstrings"), + .process("Resources/Library.xcstrings"), + .process("Settings/Settings.xcstrings"), + .process("Dictation/Dictation.xcstrings"), .process("Audio/Audio.xcstrings"), + .copy("Resources/EquationRenderer"), + .copy("Resources/ThirdPartyNotices"), + .copy("Resources/Fonts"), + .process("Resources/BrandAssets.xcassets"), + ] + ), + .testTarget( + name: "MapleAppCoreTests", + dependencies: ["MapleAppCore", .product(name: "MapleSDK", package: "swift")] + ), + .testTarget( + name: "MapleAppUITests", dependencies: ["MapleAppUI", .product(name: "Textual", package: "textual")]), + ] +) diff --git a/apps/maple-ios/README.md b/apps/maple-ios/README.md new file mode 100644 index 000000000..e36b6d9ba --- /dev/null +++ b/apps/maple-ios/README.md @@ -0,0 +1,293 @@ +# Maple for iOS + +Native SwiftUI Maple for iPhone and iPad, targeting iOS 26+. The app uses +`MapleAppCore` for account-owned workflows, `MapleAppUI` for presentation, and +the in-tree Swift `MapleSDK` for OpenSecret authentication and encrypted APIs. +macOS supports package tests only; the app target supports iPhone and iPad. + +The development app is displayed as **Maple Native Dev**, with bundle and callback +scheme `cloud.opensecret.maple.native.dev`. Its Keychain service and standard +preferences container are separate from Research. It does not import saved +sign-in or local drafts from another app identity. Research development build +profiles remain independent. + +Message rendering uses the local pinned Textual runtime in `Vendor/textual`. +Its bounded Copy export corrections and per-file provenance are documented in +[the rendering guide](docs/rendering.md) and +[the vendor patch notes](Vendor/textual/MAPLE-PATCH.md). Keep that runtime out +of app-wide formatting; update its source and provenance together rather than +editing generated SwiftPM checkouts. + +## Build and test + +From the Maple repository root, select Xcode 26.5 and use the pinned Apple +shell. No project generator, global Swift tools or signing credentials are +required for simulator builds. + +```sh +nix develop --no-update-lock-file .#apple -c bash scripts/ci/ios-native.sh unit +nix develop --no-update-lock-file .#apple -c bash scripts/ci/ios-native.sh simulator-build +``` + +The checked-in `Maple.xcodeproj` contains the `Maple` scheme. `PR` always uses +fixed public development origins and ignores local overrides. Build success +does not exercise those services or prove authentication. + +The independent `ios-native.yml` workflow also runs `cross-build` for Swift +package device/simulator compilation and `device-build` for an unsigned device +app. The `ios_native` component hook runs format lint and deterministic unit +tests only. SDK-only edits do not select consumer packaging; an app manifest +change does. Native app changes never select Research packaging. Use +`scripts/ci/ios-native.sh format` inside the same Apple shell to apply the +Xcode-pinned formatter. `MAPLE_IOS_SWIFT_SCRATCH_PATH` can isolate package builds. + +For a local stack, preserve its managed configuration and import only the +public API, billing and flags URLs: + +```sh +nix develop --no-update-lock-file .#apple -c python3 apps/maple-ios/scripts/configure-local.py \ + --env-file /absolute/path/to/public-client.env \ + --client-id PUBLIC_APPLICATION_UUID \ + --auth-url http://127.0.0.1:WEB_PORT +nix develop --no-update-lock-file .#apple -c apps/maple-ios/scripts/build.sh build local +``` + +For an installed simulator app or selected UI tests, choose a simulator from +`xcrun simctl list devices available`, then use its exact UDID: + +```sh +MAPLE_IOS_SIMULATOR_ID=SIMULATOR_UDID nix develop --no-update-lock-file .#apple \ + -c apps/maple-ios/scripts/build.sh runtime-build local +``` + +`runtime-build` runs `build-for-testing`; it builds the app and UI test runner +without installing, launching or executing tests. The deterministic package +`unit` check above does not execute UI tests either. To run one self-contained +composer fixture, use the generated `.xctestrun` file in that build's +`Build/Products` directory and the same simulator: + +```sh +nix develop --no-update-lock-file .#apple -c env -u LD -u LDPLUSPLUS -u CC -u CXX \ + xcrun xcodebuild -jobs 2 test-without-building \ + -xctestrun /absolute/path/to/generated.xctestrun \ + -destination 'platform=iOS Simulator,id=SIMULATOR_UDID' \ + -parallel-testing-enabled NO \ + -only-testing:MapleUITests/OwnedComposerUITests/testSoftwareReturnRemainsNewline +``` + +This example requires the standard software keyboard to be visible and uses +synthetic in-memory text; it does not require account credentials or services. +Rebuild after relevant source changes and record the selected method, artifact, +simulator and authoritative test result. Hardware Return sends on iPhone; +software Return and Shift-Return insert newlines and continue Markdown lists. +An empty draft does not submit or create a failure notice. + +`build.sh test-simulator local` instead builds and runs the **entire** UI target; +it does not accept a test selector. That target also contains input-delivery +diagnostics and tests with additional setup. `ComposerKeyboardUITests` and the +hardware-key cases in `OwnedComposerUITests` test the available injection path; +missing or stripped key/modifier delivery does not prove app keyboard behavior. +Run those exact diagnostic methods with their native receipt checks, preserving +failures separately from product results. + +Real-account UI suites use `MAPLE_RUN_LOCAL_UI=1` in the test runner environment +and need the signed-in local account or consumed private fixture inputs described +in their source. Some create/delete test records or sign out, so select them +against a dedicated local fixture. `ReferenceComparisonUITests` separately uses +`MAPLE_RUN_REFERENCE_UI=1` and a capture descriptor. `BrowserUITests` requires +`MAPLE_LINK_FIXTURE_URL` and a matching local page fixture. Enabling a flag alone +does not supply these prerequisites, and skipped cases do not establish coverage. +Keep credentials out of launch arguments and XCTest typing logs; use ordinary +sign-in or the test's private consumed-input channel. + +These runtime modes use Xcode's ad-hoc simulator identity, which provides the +application entitlement required by Keychain. They need no developer team, +certificate or provisioning profile. Compile-only `build` and `device-build` +remain unsigned. The runtime modes also select debug compilation for the +local Swift packages and only the selected simulator architecture. + +The env file must provide `VITE_OPEN_SECRET_API_URL`, +`VITE_MAPLE_BILLING_API_URL` and `VITE_OS_FLAGS_BASE_URL`. Every local URL must +be an HTTP loopback origin with an explicit port. The client UUID is public +application configuration, not an account credential. The script never +sources the file or imports unrelated keys. Account credentials are entered +through normal sign-in and kept in the SDK secure store. + +`Local` is a debug build. `Release` is an optimized development build using +the same explicit local configuration; run `build release` to inspect it. +Both remain `cloud.opensecret.maple.native.dev`. `device-build` compiles an unsigned +device artifact; installation/provisioning and physical validation remain +separate. These commands do not publish or upload anything. + +By default artifacts are in `.build/xcode-/Build/Products/` under +this directory. `MAPLE_IOS_DERIVED_DATA` can choose another build-output path. +Read `CFBundleIdentifier` and the `MAPLE_*` public settings from the built +`Maple.app/Info.plist` from `runtime-build`, then use `xcrun simctl install SIMULATOR_UDID APP_PATH` +and `xcrun simctl launch SIMULATOR_UDID cloud.opensecret.maple.native.dev` to target +that exact build. Simulator runtime evidence and physical-device evidence +must be recorded separately. + +The main menu remembers explicitly expanded projects in an account-scoped, +protected local file excluded from backups. These presentation choices survive +relaunch and sign-out, never sync to the server, and are removed after account +deletion. Search can reveal project contents without changing the saved choice. +Project titles expand or collapse their folders; View project in the context +menu opens the existing scoped detail view. Detail reuses the library’s chat +selection controls and instructions editor. Its search stays separate from the +retained sidebar query. Running and completed-unread indicators follow the +account-owned runtimes, including pending project chat creation; selecting or +showing a completed chat clears its unread indicator. + +The selected, visible foreground conversation polls items at 2, 5, 10, 15, +20 and 30 seconds, then every 60 seconds, skipping an active response stream. +The visible foreground menu polls the first recents page every 60 seconds and +after conversation creation. Reads preserve loaded older pages and account +ownership; hiding, retirement and deletion fence late results. Polls are +serialized, so request duration adds to each delay. + +On compact layouts, a retained `UINavigationController` owns the full-screen +Main Menu and Chat routes, their root navigation bar, and the standard +interactive back transition. UIKit controls drag progress, flick completion +and cancellation. The admission gate limits this transition to an owned +physical-left-edge contact so transcript and nested code/table scrolling keep +their gestures. Library keeps its SwiftUI stack for Settings and deeper +destinations; that stack takes over the bar while a destination is open. +Root bar publications are fenced by the account/controller lease and the +current chat owner, and retiring a lease removes their controls. + +## Account status and catalog + +Billing status, team seats and the authenticated model catalog have independent +loading/failure states. A missing or failed plan is never Free. Only a known +Free guest is restricted as a Free guest; read-only plan, quota and seat +messages do not provide purchasing or management actions. Quick/Powerful +remain server-owned aliases; catalog failures keep the saved model sendable. +Disabled, deprecated and non-chat models cannot +be selected; every non-Free catalog tier requires a paid plan for selection. +Web search starts enabled and follows its saved preference, and read-aloud +blocks only a known Free plan; the server authorizes every request. +The options sheet offers the selected catalog entry's thinking levels. A saved +choice the model does not support resolves to Default without overwriting the +preference, and is omitted from the request. Queued turns retain the effort +captured at submission. Settings has a read-only Usage section with the plan, +usage meter, reset and credit balance. Team seat notices stay in the library; +neither surface opens pricing or team management. + +Billing uses a captured account SDK API to mint a third-party token for the +configured billing origin. It caches that token only in memory and remints +once on HTTP 401; it never signs the SDK out for a billing failure. Retiring an +account invalidates pending billing and catalog results, including a later +sign-in to the same principal. + +The opt-in `LocalAccountIntegrationTests` exercise disposable local Free and +Pro accounts through the actual billing and catalog adapters. They require +`MAPLE_IOS_ACCOUNT_LIVE=1`, public loopback origins in +`MAPLE_IOS_ACCOUNT_API`, `BILLING`, `FLAGS` and `AUTH` (each with the +`MAPLE_IOS_ACCOUNT_` prefix), a public `MAPLE_IOS_ACCOUNT_CLIENT_ID`, and the +`MAPLE_IOS_ACCOUNT_PRO_EMAIL`/`PASSWORD` and `FREE_EMAIL`/`PASSWORD` fixture +values in a private child environment. Run the Swift test filter directly +inside the Apple shell; the normal unit/CI recipe deliberately removes those +opt-ins. These read-only checks neither provision accounts nor make purchases. + +## Local persistence + +`ProtectedChatStore` keeps unsent drafts and recovery identities in one atomic +account manifest. It does not cache server transcripts. Image bytes live in +immutable content-addressed files inside the same account namespace; attachment +IDs cannot overwrite another draft's bytes. The manifest remains small when a +draft includes a supported 20 MiB image. Retained work is bounded to ten images +per draft, 640 retained images and 256 MiB of image bytes per account. Shared image +identities with unchanged bytes count once across draft/edit/recovery references. Metadata +has a separate 16 MiB limit; a failed admission preserves the previous snapshot. + +Queue storage includes the original item and position, the working editor, and +the held ordinary draft in the same manifest. Up to sixteen items may wait in +one chat; the account retains up to sixty-four queued turns, including a popped +item that is actively sending. If that active item is provably rejected when all +sixteen waiting slots are filled, a persisted recovery marker retains it as a +seventeenth head item until explicitly resumed or removed. New admission stays +limited to sixteen; recovery never drops accepted work. A queued baseline is limited to 32 KiB of UTF-8 +text. Working editor text can exceed that limit while the user revises it; an +unsuccessful queue Save must preserve that work and the original item. + +A fresh direct send starts a new queue session when the prior queue is empty. +Pauses on queues with pending messages or an unfinished edit still require an +explicit resume after interruption or restoration. + +Files and directories are excluded from backups and use Complete Protection on +iOS, with restrictive Unix permissions as an additional measure. The runtime +must report protected-data availability: lock defers writes without deleting +saved work. Account retirement revokes the generation before cleanup and blocks +a new lease until cleanup succeeds. A new sign-in gets a new generation even +when the principal is unchanged. + +Version 2 manifests reference blobs by SHA256 and byte count. Version 1 inline +images migrate on the next successful save. Missing/corrupt files and unknown +versions fail closed without replacing saved work. Writes publish protected +image files before atomically replacing the manifest. Old and new image sets +can coexist during that transaction; retained limits apply to the committed +state. Orphan collection runs before another write and on activation, with +bounded directory scans, and sign-out requires complete owned-file cleanup. + +New Chat moves retain a stable storage-owner identity and tombstone so delayed +saves cannot recreate an old draft. This identity survives composer swaps and +queue editing while a move is awaiting persistence. Replaying a completed move preserves newer destination edits and +any fresh New Chat draft. Storage tests exercise these cases, interruption at +both publication boundaries, restart, locked-file deferral, and symlink/hardlink +rejection. Actual iOS protection while a physical device is locked remains a +separate device validation boundary. + +## Resources and localization + +Shared UI catalogs live in `Sources/MapleAppUI/Resources`; Audio, Dictation and +Settings also have catalogs in their feature directories. `Package.swift` +explicitly lists each processed catalog, so add new catalogs there as well as +their source files. System permission and app name translations live in +`Resources/InfoPlist.xcstrings`. UI package code uses its resource bundle. +Keep complete localized messages and translator context; format dates, numbers +and plurals for the active locale. Verify expanded/RTL layouts and dynamic text +without translating user content. Catalog presence and compilation do not prove +export/import, layout, selection, or accessibility behavior. + +Library and Settings helper keys are fixed app-owned strings maintained in +their feature catalogs. Keep these catalog entries marked with manual +extraction state: the helper intentionally creates its localized key only at +the explicit feature-table lookup. A LocalizedStringKey parameter or property +can make Xcode extract a literal into the default table before the helper +selects its feature table. Do not use these helpers for user content or +interpolated messages; use an explicit table-aware localized message for +interpolation and plurals. When adding a helper key, add its complete English +entry and translator context to the matching catalog. + +Export and import localizations in a disposable copy first. Review catalog +changes for stale required entries, new duplicate default-table keys and +preserved translator comments, then compile and inspect the imported language. +Record the configuration actually used by Xcode extraction; export success +does not establish the app's runtime endpoint profile. + +The build embeds existing native Array and Manrope assets directly from their +tracked repository locations. Array is used for the New Chat hero and Manrope +for UI/chat. Geist Mono Regular is bundled unchanged for code and metadata; +its source, version and file hash are recorded in `Resources/FontLicenses/GeistMono-PROVENANCE.json`. +Chat font choices and system script fallback remain available. The native composer +retains UIKit’s preferred body font for system text accessibility. Font files are not +converted or downloaded at runtime. License notices are bundled under `FontLicenses`. + +The shared brand styles use Maple 500 for primary fills and Neutral 900 for +readable labels. Small controls use Maple 900 in light mode and Maple 500 in dark +mode. Neutral and Pebble surfaces keep accent use limited to actions and focus. +Primary actions use capsules, a 44-point minimum target and a 200ms press animation; +Reduce Motion disables the scale animation. Semantic errors retain their contrast +adaptation independently of the brand accent. + +Software license notices and Textual provenance are copied into the UI resource +bundle under `ThirdPartyNotices`. Keep the verbatim notices, source revisions +and hashes in step with dependency changes. KaTeX and math-font notices remain +in their existing resource directories, and font notices remain in +`FontLicenses`. Inspect the produced app bundle to verify those resources are +present; source files alone do not establish packaging. + +The app uses a single resizable iPad window. Mac Catalyst, Designed for iPad +on Mac, and visionOS compatibility are disabled in the project. Final App +Store availability, production identifiers, associated domains, entitlements, +privacy declarations and release ownership require a separate review. diff --git a/apps/maple-ios/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/maple-ios/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..a1c19d092 --- /dev/null +++ b/apps/maple-ios/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "idiom": "universal", + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0.6588235294117647", + "green": "0.20784313725490197", + "blue": "0.08235294117647059" + } + } + }, + { + "idiom": "universal", + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "1.0", + "green": "0.592156862745098", + "blue": "0.44313725490196076" + } + }, + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/maple-ios/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/apps/maple-ios/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 000000000..892a6fdfc Binary files /dev/null and b/apps/maple-ios/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/apps/maple-ios/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/maple-ios/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..5d3ac93d3 --- /dev/null +++ b/apps/maple-ios/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,4 @@ +{ + "images": [{ "filename": "AppIcon.png", "idiom": "universal", "platform": "ios", "size": "1024x1024" }], + "info": { "author": "xcode", "version": 1 } +} diff --git a/apps/maple-ios/Resources/Assets.xcassets/Contents.json b/apps/maple-ios/Resources/Assets.xcassets/Contents.json new file mode 100644 index 000000000..ac502da0a --- /dev/null +++ b/apps/maple-ios/Resources/Assets.xcassets/Contents.json @@ -0,0 +1 @@ +{ "info": { "author": "xcode", "version": 1 } } diff --git a/apps/maple-ios/Resources/Assets.xcassets/LaunchWordmark.imageset/Contents.json b/apps/maple-ios/Resources/Assets.xcassets/LaunchWordmark.imageset/Contents.json new file mode 100644 index 000000000..aa64ed242 --- /dev/null +++ b/apps/maple-ios/Resources/Assets.xcassets/LaunchWordmark.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "maple-wordmark.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/apps/maple-ios/Resources/Assets.xcassets/LaunchWordmark.imageset/maple-wordmark.svg b/apps/maple-ios/Resources/Assets.xcassets/LaunchWordmark.imageset/maple-wordmark.svg new file mode 100644 index 000000000..3fe2009b2 --- /dev/null +++ b/apps/maple-ios/Resources/Assets.xcassets/LaunchWordmark.imageset/maple-wordmark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/apps/maple-ios/Resources/BrandSources/AppIcon-PROVENANCE.json b/apps/maple-ios/Resources/BrandSources/AppIcon-PROVENANCE.json new file mode 100644 index 000000000..5c90e574d --- /dev/null +++ b/apps/maple-ios/Resources/BrandSources/AppIcon-PROVENANCE.json @@ -0,0 +1,21 @@ +{ + "source": "app-icon.svg", + "source_sha256": "396918c9a261d3fb8d742b55faf9dd10c73c152bd16aeffb2e639cf9eedf0b38", + "output": "../Assets.xcassets/AppIcon.appiconset/AppIcon.png", + "output_sha256": "e49809af9c5a3b5106798da5ac7598e0e217e1da7c0b372b956889a1fcb9eb59", + "renderer": { + "sharp": "0.35.4", + "librsvg": "2.62.91", + "libvips": "8.18.6" + }, + "render": { + "density": 4608, + "intermediate_size": 4096, + "output_size": 1024, + "kernel": "lanczos3", + "flatten": "#FFFFFF", + "alpha": false, + "space": "srgb" + }, + "artwork_changes": "None" +} diff --git a/apps/maple-ios/Resources/BrandSources/README.md b/apps/maple-ios/Resources/BrandSources/README.md new file mode 100644 index 000000000..040097451 --- /dev/null +++ b/apps/maple-ios/Resources/BrandSources/README.md @@ -0,0 +1,12 @@ +# Native brand artwork + +`app-icon.svg` is the original Maple glyph and gradient. The app icon is rendered +with librsvg at 4× its final resolution and downsampled to 1024×1024. Its original +transparency is flattened over Neutral 0 (`#FFFFFF`) for an opaque native app +icon; the glyph, gradient and vector geometry are unchanged. The visible app name +remains Maple Native Dev. + +`AppIcon-PROVENANCE.json` records the exact source and output hashes and renderer. +Do not substitute a renderer that omits SVG gradient transforms. The wordmark in +the UI resource asset catalog remains a vector template, so native foreground +colors can follow light and dark appearance. diff --git a/apps/maple-ios/Resources/BrandSources/app-icon.svg b/apps/maple-ios/Resources/BrandSources/app-icon.svg new file mode 100644 index 000000000..6e9500fbc --- /dev/null +++ b/apps/maple-ios/Resources/BrandSources/app-icon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/apps/maple-ios/Resources/FontLicenses/Array-FFL.txt b/apps/maple-ios/Resources/FontLicenses/Array-FFL.txt new file mode 100644 index 000000000..8ae620038 --- /dev/null +++ b/apps/maple-ios/Resources/FontLicenses/Array-FFL.txt @@ -0,0 +1,132 @@ +Fontshare + +------------------------------------------------------------------ +ITF Free Font License (FFL) +Version 2.0 - 17 Aug 2026 +------------------------------------------------------------------ + +Notice to User + +Indian Type Foundry designs, produces and distributes font software as digital fonts to end users worldwide. In addition to commercial fonts that are available for a fee, ITF also offers several fonts that can be used free of charge. These free fonts are distributed through a dedicated platform called www.fontshare.com (“Fontshare”) to end users worldwide. Fonts identified on Fontshare as being licensed under the ITF Free Font License are subject to this legally binding EULA between Indian Type Foundry (“Indian Type Foundry”, “ITF” or “Licensor”) and you (“Licensee”). + +You acknowledge that the Font Software and the designs embodied therein are protected by copyright, other intellectual property and industrial property rights, and international treaties. They are and shall remain at all times the intellectual property of Indian Type Foundry. + +In addition to direct download, Fontshare may also make the Font Software available through the Fontshare API. In this case, the Font Software is delivered directly from servers used by Indian Type Foundry to the Licensee's website, without the Licensee having to download or host the Font Software. + +By downloading, accessing, self-hosting, installing, storing, embedding, copying or otherwise using the Font Software, you agree to the following terms. + + +Definitions + +“Font Software” refers to the computer files or programs released under this License that enable the display, reproduction and rendering of the typeface designs, including letters, numerals, punctuation, symbols, ornaments and other typographic characters. Font Software includes all font formats and bitmap or vector representations distributed by Indian Type Foundry under this License. + +“Official Version” refers to the Font Software as officially distributed by Indian Type Foundry through Fontshare. + +“Derivative Work” refers to any modified, adapted, altered or otherwise derived version of the Font Software or the typeface designs embodied therein, including modifications to font files (such as OTF, TTF, WOFF, WOFF2 or variable fonts), glyphs, characters, spacing, kerning, metrics, naming, subsetting, format conversion or other font data. + +Derivative Work does not include any logo, document, graphic, image, website, application or other creative work created through the normal use of the Font Software, ownership of which remains with its respective creator or rights holder. + + +01 Grant of License + +You are hereby granted a non-exclusive, non-assignable, non-transferable and terminable license to access, download, install, store and use the Font Software for personal or commercial purposes, free of charge and for an unlimited period of time, subject to the terms of this License. + +You may use the Font Software in any media, including Print, Websites, Mobile or Desktop Applications, Digital Images, Digital Advertising, Social Media, ePublishing, Video, Broadcasting and Games, at any scale and in any location worldwide. + +You may self-host the Font Software on your own servers or infrastructure for use on your own websites and applications, including through standard webfont technologies such as CSS @font-face. Self-hosting by end users is permitted and recommended for greater control, reliability and performance. Use of the Fontshare API is optional and is not required for web use. + +You may embed the Font Software in mobile or desktop applications and digital documents for the uses permitted under this License. + +You may use the Font Software to create logos, wordmarks, graphic elements, images, vector files, scalable drawings and other creative works. Logos and wordmarks created using the Font Software may be registered as trademarks. Any rights in the resulting creative works, including any trademark rights, remain with their respective creator or rights holder. Such rights do not grant any ownership or exclusive rights in the Font Software or the underlying typeface designs. + +You may use the Font Software on any number of devices, including computers, tablets and phones. The number of output devices, including printers, is not restricted. Where the Licensee is an organization, the Font Software may be installed, stored, copied and shared internally among its employees for the Licensee's own permitted uses under this License. Such internal sharing does not constitute redistribution under this License. + +You may make a reasonable number of backup copies of the Font Software as necessary for your permitted use. + +You may, but are not required to, identify or credit Indian Type Foundry or Fontshare in works created using the Font Software. + + +02 Limitations of Usage + +You may not modify, edit, adapt, translate, reverse engineer, decompile, disassemble or otherwise alter the Font Software or the typeface designs embodied therein, in whole or in part, without the prior written consent of the Licensor. This includes modifying or replacing glyphs, subsetting, format conversion, or altering font names, copyright information, ownership information or other metadata. + +The Font Software may not, beyond the permitted copies and uses defined herein, be distributed, duplicated, loaned, resold, sublicensed, transferred, donated, given away or otherwise made available to any other person or entity, whether for free or for a fee. This includes distributing the Font Software through another font website, font library, marketplace, repository, download service, application or platform, or by email, removable media, publicly accessible servers, file-sharing services, peer-to-peer networks or any other means. + +You may not provide the Font Software directly to external designers, agencies, contractors, printers or other service providers. Any third party wishing to use the Font Software must obtain their own copy directly from Fontshare and be independently bound by this License. + +You may not host, serve, embed or otherwise make the Font Software available for use by third parties through any website, application, online service, SaaS platform, design tool, template editor or similar service. This includes making the Font Software available as a selectable font for third-party users to create, edit, customize or generate their own content, even where such users cannot download or extract the Font Software itself. + +For the avoidance of doubt, nothing in this Section 02 restricts the self-hosting, embedding or other use of the Font Software by the Licensee for the Licensee's own websites, applications or other permitted uses under Section 01. + + +03 Embedding + +You may embed the Font Software in PDFs and other digital documents for your permitted uses under this License, provided that the embeddedFont Software cannot be extracted or used independently from the document. + +Documents containing embedded Font Software may be distributed without restriction as to the number of recipients. + +The extraction of the Font Software, in whole or in part, for independent use is prohibited. + + +04 Third-party use, Commercial print service providers + +You may provide documents, artwork or other files created using the Font Software to commercial printers and other service providers for printing, production or display purposes. + +If a service provider requires access to the Font Software itself, they must obtain their own copy directly from Fontshare and be independently bound by this License. + + +05 Derivative Work + +You may not create a Derivative Work from the Font Software without the prior written consent of the Licensor. + +Where the Licensor expressly permits the creation of a Derivative Work, such permission is limited to the specific use authorized by the Licensor and does not grant any ownership or other rights in the Official Version of the Font Software or the underlying typefacedesigns. + +A Derivative Work may not be sublicensed, sold, leased, rented, loaned, distributed, transferred or otherwise made available to any third party without the prior written consent of the Licensor. + + +06 Warranty and Liability + +THE FONT SOFTWARE IS PROVIDED FREE OF CHARGE AND ON AN “AS IS” AND “AS AVAILABLE” BASIS. TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, INDIAN TYPE FOUNDRY MAKES NO WARRANTIES, EXPRESS OR IMPLIED, INCLUDING WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, COMPATIBILITY, AVAILABILITY OR OTHERWISE. + +TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, INDIAN TYPE FOUNDRY SHALL NOT BE LIABLE TO THE LICENSEEOR ANY THIRD PARTY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL OR CONSEQUENTIAL DAMAGES, INCLUDING LOSS OF PROFITS, REVENUE, DATA, BUSINESS OR SAVINGS, OR BUSINESS INTERRUPTION, ARISING OUT OF THE USE OF OR INABILITY TO USE THE FONT SOFTWARE. + +IF THE LICENSEE CHOOSES TO ACCESS OR SERVE THE FONT SOFTWARE THROUGH THE FONTSHARE API, THE LICENSEE DOES SO AT ITS OWN RISK. THE FONTSHARE API IS PROVIDED AS A CONVENIENCE AND DOES NOT FORM PART OF THE RIGHTS GRANTED TO THE LICENSEE TO USE THE FONT SOFTWARE UNDER THIS LICENSE. + +INDIAN TYPE FOUNDRY DOES NOT GUARANTEE THE AVAILABILITY, PERFORMANCE OR CONTINUED OPERATION OF THE FONTSHARE API. INDIAN TYPE FOUNDRY MAY, AT ITS SOLE DISCRETION AND WITHOUT PRIOR NOTICE, MODIFY, RESTRICT, SUSPEND OR DISCONTINUE ACCESS TO THE FONTSHARE API, WHETHER GENERALLY OR FOR ANY PARTICULAR USER, WEBSITE OR APPLICATION. INDIAN TYPE FOUNDRY SHALL NOT BE RESPONSIBLE OR LIABLE FOR ANY INTERRUPTION, MALFUNCTION, DOWNTIME, CHANGE, RESTRICTION OR DISCONTINUATION OF THE API, OR FOR ANY RESULTING IMPACT ON THE LICENSEE'S WEBSITE, APPLICATION OR OTHER SERVICES. + + +07 Updates, Maintenance and Support Services + +Indian Type Foundry is under no obligation to provide updates, upgrades, maintenance, technical support or other support services for the Font Software under this License. + +Indian Type Foundry may, at its sole discretion, update, modify, replace or discontinue any Font Software made available through Fontshare. + + +08 Termination + +Any breach of the terms of this License shall be grounds for termination. Where the breach is capable of being remedied, the Licensor may notify the Licensee in writing and require the breach to be remedied within 30 days of receipt of such notice. + +Notwithstanding the foregoing, the Licensor may terminatethis License immediately in the event of any deliberate or material unauthorized modification, redistribution, sale, sublicensing, transfer or other misuse of the Font Software or the intellectual property rights of Indian Type Foundry. + +Upon termination, and without limitation of any other remedies available under law or equity, the Licensee must immediately cease all use of the Font Software and delete the Font Software and all copies thereof inits possession or control. Proof of deletion must be provided upon request of the Licensor. + +Indian Type Foundry reserves the right to seek damages and any other remedies available under applicable law for any breach of this License. + + +09 Final Provisions + +If any provision of this License is found to be invalid, illegal or unenforceable, the remaining provisions shall remain in full force and effect. Any invalid, illegal or unenforceable provision shall, to the extent legally permissible, be interpreted or replaced in a manner that most closely reflects its original purpose. + +The failure of Indian Type Foundry to enforce any provision of this License shall not constitute a waiver of that provision or of its right to enforce it subsequently. + +The uses expressly permitted under this License require no additional license or fee. If a Licensee requires additional rights or permissions, exceptions to any restrictions under this License, modifications or customizations to the Font Software or typeface designs, additional styles, weights, widths, characters or language extensions, technical support ormaintenance services, warranties or indemnities, or other customized licensing terms, they may contact Indian Type Foundry to discuss a separate custom license or arrangement. + +This License shall be governed by the laws ofthe Republic of India. To the extent legally permissible, the courts located in Ahmedabad, Gujarat, India shall have exclusive jurisdiction over any dispute arising out of or in connection with this License. + +------------------------------------------------------------------ +ITF Free Font License (FFL) +Version 2.0 - 17 Aug 2026 + +Copyright © 2026 Indian Type Foundry. All rights reserved. +https://www.fontshare.com +------------------------------------------------------------------ \ No newline at end of file diff --git a/apps/maple-ios/Resources/FontLicenses/GeistMono-OFL.txt b/apps/maple-ios/Resources/FontLicenses/GeistMono-OFL.txt new file mode 100644 index 000000000..04e95fc55 --- /dev/null +++ b/apps/maple-ios/Resources/FontLicenses/GeistMono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/apps/maple-ios/Resources/FontLicenses/GeistMono-PROVENANCE.json b/apps/maple-ios/Resources/FontLicenses/GeistMono-PROVENANCE.json new file mode 100644 index 000000000..c35687ed6 --- /dev/null +++ b/apps/maple-ios/Resources/FontLicenses/GeistMono-PROVENANCE.json @@ -0,0 +1,12 @@ +{ + "family": "Geist Mono", + "style": "Regular", + "upstream": "https://github.com/vercel/geist-font", + "license": "SIL Open Font License 1.1", + "license_file": "GeistMono-OFL.txt", + "asset": "../../Sources/MapleAppUI/Resources/Fonts/GeistMono-Regular.ttf", + "sha256": "42d8ad2e610238e64e8abfcde3037c63f7850a73928742b7ab7229d897bcb155", + "modifications": "None", + "upstreamRelease": "v1.7.2", + "fontVersion": "1.700" +} diff --git a/apps/maple-ios/Resources/FontLicenses/Manrope-OFL.txt b/apps/maple-ios/Resources/FontLicenses/Manrope-OFL.txt new file mode 100644 index 000000000..472064afc --- /dev/null +++ b/apps/maple-ios/Resources/FontLicenses/Manrope-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2018 The Manrope Project Authors (https://github.com/sharanda/manrope) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/maple-ios/Resources/Info.plist b/apps/maple-ios/Resources/Info.plist new file mode 100644 index 000000000..0d19989ec --- /dev/null +++ b/apps/maple-ios/Resources/Info.plist @@ -0,0 +1,35 @@ + + + + + CFBundleDevelopmentRegionen + CFBundleDisplayNameMaple Native Dev + CFBundleExecutable$(EXECUTABLE_NAME) + CFBundleIdentifier$(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion6.0 + CFBundleName$(PRODUCT_NAME) + CFBundlePackageTypeAPPL + CFBundleShortVersionString$(MARKETING_VERSION) + CFBundleVersion$(CURRENT_PROJECT_VERSION) + CFBundleURLTypes + CFBundleURLNameNative authenticationCFBundleURLSchemes$(MAPLE_CALLBACK_SCHEME) + ITSAppUsesNonExemptEncryption + LSRequiresIPhoneOS + NSCameraUsageDescriptionTake a photo to attach to your message. + NSMicrophoneUsageDescriptionRecord your voice to turn it into message text. + NSAppTransportSecurityNSAllowsLocalNetworking + UIApplicationSceneManifestUIApplicationSupportsMultipleScenes + UILaunchStoryboardNameLaunchScreen + UIBackgroundModesaudio + UISupportedInterfaceOrientationsUIInterfaceOrientationPortraitUIInterfaceOrientationLandscapeLeftUIInterfaceOrientationLandscapeRight + UISupportedInterfaceOrientations~ipadUIInterfaceOrientationPortraitUIInterfaceOrientationPortraitUpsideDownUIInterfaceOrientationLandscapeLeftUIInterfaceOrientationLandscapeRight + UIAppFontsArray-Regular.otfManrope-VariableFont_wght.ttfGeistMono-Regular.ttf + MAPLE_PROFILE$(MAPLE_PROFILE) + MAPLE_API_URL$(MAPLE_API_URL) + MAPLE_BILLING_URL$(MAPLE_BILLING_URL) + MAPLE_FLAGS_URL$(MAPLE_FLAGS_URL) + MAPLE_AUTH_URL$(MAPLE_AUTH_URL) + MAPLE_CLIENT_ID$(MAPLE_CLIENT_ID) + MAPLE_CALLBACK_SCHEME$(MAPLE_CALLBACK_SCHEME) + + diff --git a/apps/maple-ios/Resources/InfoPlist.xcstrings b/apps/maple-ios/Resources/InfoPlist.xcstrings new file mode 100644 index 000000000..a93c4d653 --- /dev/null +++ b/apps/maple-ios/Resources/InfoPlist.xcstrings @@ -0,0 +1,21 @@ +{ + "sourceLanguage": "en", + "strings": { + "CFBundleDisplayName": { + "comment": "Name displayed below the native development app icon, distinct from Research.", + "extractionState": "manual", + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Maple Native Dev" } } } + }, + "NSCameraUsageDescription": { + "comment": "Camera permission explanation when attaching a photo to chat.", + "extractionState": "manual", + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Take a photo to attach to your message." } } } + }, + "NSMicrophoneUsageDescription": { + "comment": "Microphone permission explanation for inline dictation.", + "extractionState": "manual", + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Record your voice to turn it into message text." } } } + } + }, + "version": "1.0" +} diff --git a/apps/maple-ios/Resources/LaunchScreen.storyboard b/apps/maple-ios/Resources/LaunchScreen.storyboard new file mode 100644 index 000000000..1610eff59 --- /dev/null +++ b/apps/maple-ios/Resources/LaunchScreen.storyboard @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/maple-ios/Resources/PrivacyInfo.xcprivacy b/apps/maple-ios/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 000000000..fa414e813 --- /dev/null +++ b/apps/maple-ios/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,11 @@ + + + + NSPrivacyTracking + NSPrivacyTrackingDomains + NSPrivacyCollectedDataTypes + NSPrivacyAccessedAPITypes + NSPrivacyAccessedAPITypeNSPrivacyAccessedAPICategoryUserDefaultsNSPrivacyAccessedAPITypeReasonsCA92.1 + NSPrivacyAccessedAPITypeNSPrivacyAccessedAPICategoryFileTimestampNSPrivacyAccessedAPITypeReasonsC617.1 + + diff --git a/apps/maple-ios/Sources/MapleAppCore/Account/AccountEntitlements.swift b/apps/maple-ios/Sources/MapleAppCore/Account/AccountEntitlements.swift new file mode 100644 index 000000000..93c4f4015 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Account/AccountEntitlements.swift @@ -0,0 +1,288 @@ +import Foundation +import MapleSDK +import Observation + +public enum AccountAccess: Equatable, Sendable { + case allowed + case unavailable, unknownPlan, freeGuest, usageLimit, seatMismatch + case paidPlanRequired, modelUnavailable, modelIncompatible, retired +} + +public struct AccountModelChoice: Equatable, Sendable, Identifiable { + /// Alias IDs remain aliases in requests; targetModel is presentation metadata. + public let id: String + public let targetModel: String + public let label: String + public let shortName: String + public let description: String? + public let contextWindow: Int + public let accessTier: String + public let capabilities: ModelCapabilities + public let badges: [String] + public let isAlias: Bool + public let sortOrder: Int +} + +public struct ThinkingLevelOption: Equatable, Sendable, Identifiable { + public let value: MapleThinkingLevel + public let defaultEffort: MapleThinkingLevel? + public var id: MapleThinkingLevel { value } + public var label: String { value.label } +} + +public struct ResolvedThinkingLevel: Equatable, Sendable { + public let options: [ThinkingLevelOption] + public let effective: MapleThinkingLevel + public var effort: ReasoningEffort? { effective.effort } +} + +/// Read-only entitlement state for exactly one app session. It never changes SDK +/// authentication and does not execute purchase, management or feature-flag actions. +@Observable @MainActor public final class AccountEntitlements { + public let scope: AccountScope + public let isGuest: Bool + public private(set) var billing: AccountLoadState = .unknown + public private(set) var team: AccountLoadState = .unknown + public private(set) var catalog: AccountLoadState = .unknown + public private(set) var selectedModelID: String + /// The explicit preference is the dispatch value while this session is active. + public var webSearchPreferred: Bool + public var thinkingLevel: MapleThinkingLevel + private var active = true + private var billingRequest = UUID() + private var teamRequest = UUID() + private var catalogRequest = UUID() + private let billingService: any BillingServing + private let catalogService: any ModelCatalogServing + + public init( + scope: AccountScope, isGuest: Bool, billing: any BillingServing, + catalog: any ModelCatalogServing, selectedModelID: String = ModelAliasId.quick.rawValue, + webSearchPreferred: Bool = true, thinkingLevel: MapleThinkingLevel = .default + ) { + self.scope = scope + self.isGuest = isGuest + self.billingService = billing + self.catalogService = catalog + self.selectedModelID = selectedModelID + self.webSearchPreferred = webSearchPreferred + self.thinkingLevel = thinkingLevel + } + + public var plan: BillingPlan { billing.value?.plan ?? .unknown } + + public func refresh() async { + guard active else { return } + async let status: Void = refreshBilling() + async let seats: Void = refreshTeam() + async let models: Void = refreshCatalog() + _ = await (status, seats, models) + } + + public func refreshBilling() async { + guard active else { return } + let request = UUID() + billingRequest = request + // Keep only this generation's currently verified value while refreshing. + // A failure replaces it; a later retry cannot recover a discarded value. + if billing.value == nil { billing = .loading } + do { + let status = try await billingService.subscriptionStatus() + try status.validate() + guard active, billingRequest == request else { return } + billing = .loaded(status) + } catch { + guard active, billingRequest == request else { return } + billing = .failed(BillingService.safe(error)) + } + } + + public func refreshTeam() async { + guard active else { return } + let request = UUID() + teamRequest = request + if team.value == nil { team = .loading } + do { + let status = try await billingService.teamStatus() + try status.validate() + guard active, teamRequest == request else { return } + team = .loaded(status) + } catch { + guard active, teamRequest == request else { return } + team = .failed(BillingService.safe(error)) + } + } + + public func refreshCatalog() async { + guard active else { return } + let request = UUID() + catalogRequest = request + if catalog.value == nil { catalog = .loading } + do { + let result = try await catalogService.fetchCatalog() + guard result.object == "list", result.data.count <= 1_024, + result.aliases.count <= 16 + else { throw AccountServiceError.invalidResponse } + guard active, catalogRequest == request else { return } + catalog = .loaded(result) + // A successful catalog may remove or disable a concrete model. Keep + // explicit aliases stable so the server can change their targets. + if ModelAliasId(rawValue: selectedModelID) == nil, + !advancedModels.contains(where: { $0.id == selectedModelID }) + { + selectedModelID = ModelAliasId.quick.rawValue + } + } catch { + guard active, catalogRequest == request else { return } + catalog = .failed(BillingService.safe(error)) + } + } + + /// Call before retiring the captured SDK account. Late mint, HTTP and catalog + /// completions cannot publish into this model or a replacement generation. + public func retire() async { + active = false + billingRequest = UUID(); teamRequest = UUID(); catalogRequest = UUID() + billing = .unknown; team = .unknown; catalog = .unknown + await billingService.retire() + } + + public var primaryModels: [AccountModelChoice] { + guard let catalog = catalog.value else { return [] } + return [ModelAliasId.quick, .powerful].compactMap { id in + guard let alias = catalog.aliases.first(where: { $0.id == id }), + let target = eligibleCatalogModels.first(where: { $0.id == alias.targetModel }) + else { return nil } + return AccountModelChoice( + id: alias.id.rawValue, targetModel: target.id, label: alias.label, + shortName: alias.shortName, description: alias.description, + contextWindow: target.contextWindow, accessTier: target.access, + capabilities: target.capabilities, badges: target.badges ?? [], + isAlias: true, sortOrder: id == .quick ? 0 : 1) + } + } + + public var advancedModels: [AccountModelChoice] { + eligibleCatalogModels.map { item in + AccountModelChoice( + id: item.id, targetModel: item.id, label: item.displayName, + shortName: item.shortName, description: item.description, + contextWindow: item.contextWindow, accessTier: item.access, + capabilities: item.capabilities, badges: item.badges ?? [], + isAlias: false, sortOrder: item.sortOrder ?? 999) + }.sorted { lhs, rhs in + let leftAllowed = modelAccess(lhs) == .allowed + let rightAllowed = modelAccess(rhs) == .allowed + if leftAllowed != rightAllowed { return leftAllowed } + if lhs.sortOrder != rhs.sortOrder { return lhs.sortOrder < rhs.sortOrder } + return lhs.id < rhs.id + } + } + + private var eligibleCatalogModels: [ModelCatalogItem] { + guard let catalog = catalog.value else { return [] } + var seen = Set() + return catalog.data.filter { + seen.insert($0.id).inserted && !$0.id.isEmpty && ModelAliasId(rawValue: $0.id) == nil + && $0.enabled && !$0.deprecated && $0.capabilities.chat && $0.contextWindow > 0 + } + } + + public var selectedModel: AccountModelChoice? { + (primaryModels + advancedModels).first { $0.id == selectedModelID } + } + + /// Aliases publish their own reasoning contract; do not infer it from their target. + public var selectedModelReasoning: ModelReasoning? { + guard active, let catalog = catalog.value else { return nil } + if let alias = catalog.aliases.first(where: { $0.id.rawValue == selectedModelID }) { + return alias.reasoning + } + return catalog.data.first(where: { $0.id == selectedModelID })?.reasoning + } + + public var resolvedThinkingLevel: ResolvedThinkingLevel { + Self.resolveThinkingLevel(thinkingLevel, reasoning: selectedModelReasoning) + } + + public static func thinkingLevelOptions(_ reasoning: ModelReasoning?) -> [ThinkingLevelOption] { + guard let reasoning else { return [] } + var options = [ + ThinkingLevelOption( + value: .default, defaultEffort: reasoning.defaultEffort.flatMap(MapleThinkingLevel.init(rawValue:))) + ] + if !reasoning.mandatory { options.append(.init(value: .none, defaultEffort: nil)) } + let supported = Set(reasoning.supportedEfforts ?? []) + for choice in MapleThinkingLevel.allCases where choice != .default && choice != .none { + if supported.contains(choice.rawValue) { options.append(.init(value: choice, defaultEffort: nil)) } + } + return options + } + + public static func resolveThinkingLevel( + _ choice: MapleThinkingLevel, reasoning: ModelReasoning? + ) -> ResolvedThinkingLevel { + let options = thinkingLevelOptions(reasoning) + let effective = options.contains(where: { $0.value == choice }) ? choice : .default + return ResolvedThinkingLevel(options: options, effective: effective) + } + + public func modelAccess(_ model: AccountModelChoice) -> AccountAccess { + guard active else { return .retired } + return model.accessTier == "free" ? .allowed : (plan.isPaid ? .allowed : .paidPlanRequired) + } + + @discardableResult public func selectModel(_ id: String) -> AccountAccess { + guard let choice = (primaryModels + advancedModels).first(where: { $0.id == id }) else { + return .modelUnavailable + } + let access = modelAccess(choice) + if access == .allowed { selectedModelID = id } + return access + } + + private var accountChatAccess: AccountAccess { + guard active else { return .retired } + if isGuest, billing.value?.plan == .free { return .freeGuest } + return .allowed + } + + public var chatAccess: AccountAccess { + let account = accountChatAccess + guard account == .allowed else { return account } + // Catalog availability controls the picker, not sending an existing + // selection. The server owns model, plan and usage authorization. + return LocalPreferences.isValidModelID(selectedModelID) ? .allowed : .modelUnavailable + } + + public var paidFeatureAccess: AccountAccess { + let chat = accountChatAccess + guard chat == .allowed else { return chat } + guard billing.value != nil else { return .unavailable } + guard plan != .unknown else { return .unknownPlan } + return plan.isPaid ? .allowed : .paidPlanRequired + } + + public var webSearchAccess: AccountAccess { active ? .allowed : .retired } + + public var effectiveWebSearch: Bool { webSearchPreferred && webSearchAccess == .allowed } + public var imageAccess: AccountAccess { paidFeatureAccess } + + public var transcriptionAccess: AccountAccess { + let paid = paidFeatureAccess + guard paid == .allowed else { return paid } + guard let transcription = catalog.value?.audio?.transcription else { return .unavailable } + return transcription.available ? .allowed : .modelIncompatible + } + + public var transcriptionAvailable: Bool { + transcriptionAccess == .allowed + } + public var speechAvailable: Bool { + speechAccess == .allowed + } + public var speechAccess: AccountAccess { + guard active else { return .retired } + return billing.value?.plan == .free ? .paidPlanRequired : .allowed + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Account/BillingContracts.swift b/apps/maple-ios/Sources/MapleAppCore/Account/BillingContracts.swift new file mode 100644 index 000000000..cf3b6d1c6 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Account/BillingContracts.swift @@ -0,0 +1,250 @@ +import Foundation + +public enum AccountServiceError: Error, Equatable, Sendable { + case sessionChanged, cancelled, unauthorized, unavailable, invalidResponse +} + +public enum AccountLoadState: Equatable, Sendable { + case unknown, loading, loaded(Value), failed(AccountServiceError) + + public var value: Value? { + guard case let .loaded(value) = self else { return nil } + return value + } +} + +/// Server renewal dates may be epoch seconds or an ISO 8601 string. Unknown +/// string formats remain unreadable; they are never interpreted as local dates. +public enum BillingDate: Decodable, Equatable, Sendable { + case seconds(Double), text(String) + + public init(from decoder: any Decoder) throws { + let value = try decoder.singleValueContainer() + if let seconds = try? value.decode(Double.self), seconds.isFinite { + self = .seconds(seconds) + } else { + self = .text(try value.decode(String.self)) + } + } + + public var date: Date? { + switch self { + case let .seconds(value): + guard value.isFinite, (0...253_402_300_799).contains(value) else { return nil } + return Date(timeIntervalSince1970: value) + case let .text(value): + // Deliberately do not treat numeric strings as epoch seconds. + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.date(from: value) ?? ISO8601DateFormatter().date(from: value) + } + } +} + +public struct BillingSubscription: Decodable, Equatable, Sendable { + public let provider: String + public let plan: String + public let productId: String? + public let state: String + public let renewsAt: Double? + /// Inert server data. It must never be interpreted as a URL or route. + public let manage: String + public let selected: Bool? + public let environment: String? + public let autoRenewEnabled: Bool? +} + +public enum BillingPlan: Equatable, Sendable { + public enum Paid: Equatable, Sendable { case pro, max, team } + case unknown, free, paid(Paid) + + public var isPaid: Bool { + if case .paid = self { return true } + return false + } +} + +public struct BillingConflict: Decodable, Equatable, Sendable { + public let otherProvider: String? + /// Inert server data. It must never be interpreted as a route or action. + public let action: String? +} + +public struct BillingPendingPlanChange: Decodable, Equatable, Sendable { + public let id: String? + public let type: String? + public let targetPlanName: String? + public let status: String? + public let expiresAt: String? +} + +public struct BillingStatus: Decodable, Equatable, Sendable { + public let isSubscribed: Bool + public let stripeCustomerId: String? + public let productId: String + public let productName: String? + public let subscriptionStatus: String? + public let currentPeriodEnd: BillingDate? + public let canChat: Bool + public let chatsRemaining: Int? + public let paymentProvider: String? + public let totalTokens: Int? + public let usedTokens: Int? + public let usageResetDate: String? + public let apiCreditBalance: Double? + public let iosIapEnabled: Bool? + public let iosUsExternalLinkEnabled: Bool? + public let subscriptions: [BillingSubscription]? + public let conflict: BillingConflict? + public let pendingPlanChange: BillingPendingPlanChange? + + public var plan: BillingPlan { + guard let name = productName?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() else { + return .unknown + } + if name == "free" { return .free } + if name.contains("team") { return .paid(.team) } + if name.contains("max") { return .paid(.max) } + if name.contains("pro") { return .paid(.pro) } + return .unknown + } + + func validate() throws { + guard [chatsRemaining, totalTokens, usedTokens].allSatisfy({ $0.map { $0 >= 0 } ?? true }), + subscriptions?.allSatisfy({ $0.renewsAt.map { $0.isFinite && $0 >= 0 } ?? true }) ?? true + else { throw AccountServiceError.invalidResponse } + } +} + +/// Presentation data only. A usage meter never grants or denies account access. +public struct BillingUsageSummary: Equatable, Sendable { + public enum PlanLabel: Equatable, Sendable { + case loading, product(String), productPlan(String) + } + + public struct Meter: Equatable, Sendable { + public let percentUsed: Double + public var roundedPercentUsed: Int { Int(percentUsed.rounded()) } + public let reset: BillingUsageReset + public let creditBalance: Double? + } + + public let planLabel: PlanLabel + public let meter: Meter? +} + +public enum BillingUsageReset: Equatable, Sendable { + case monthly, lessThanOneHour, hours(Int), tomorrow, days(Int), date(Date) + + static func resolve(_ value: String?, now: Date, calendar: Calendar) -> Self { + guard let value, let date = resetDate(value), + date >= now, + let days = calendar.dateComponents( + [.day], from: calendar.startOfDay(for: now), to: calendar.startOfDay(for: date) + ).day + else { return .monthly } + if days == 0 { + let hours = Int(date.timeIntervalSince(now) / 3_600) + return hours < 1 ? .lessThanOneHour : .hours(hours) + } + if days == 1 { return .tomorrow } + if (2...7).contains(days) { return .days(days) } + return .date(date) + } + + private static func resetDate(_ value: String) -> Date? { + if let date = BillingDate.text(value).date { return date } + // Research's ISO date-only reset values describe UTC midnight, not a + // local calendar date. Keep this fallback separate from renewal dates. + guard value.range(of: #"^[0-9]{4}-[0-9]{2}-[0-9]{2}$"#, options: .regularExpression) != nil else { + return nil + } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withFullDate] + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter.date(from: value) + } +} + +extension BillingStatus { + public func usageSummary(now: Date = Date(), calendar: Calendar = .current) -> BillingUsageSummary { + let name = productName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let label: BillingUsageSummary.PlanLabel + if name.isEmpty { + label = .loading + } else if name.range( + of: #"(? 0, let usedTokens else { + return BillingUsageSummary(planLabel: label, meter: nil) + } + let percent = min(100, max(0, Double(usedTokens) / Double(totalTokens) * 100)) + let credits = apiCreditBalance.flatMap { $0.isFinite && $0 > 0 ? $0 : nil } + return BillingUsageSummary( + planLabel: label, + meter: .init( + percentUsed: percent, reset: .resolve(usageResetDate, now: now, calendar: calendar), + creditBalance: credits)) + } +} + +public struct TeamSeatMismatch: Equatable, Sendable { + public let memberCount: Int? + public let billedSeatCount: Int? + public var hasExactCounts: Bool { memberCount != nil && billedSeatCount != nil } +} + +public struct TeamStatus: Decodable, Equatable, Sendable { + public let hasTeamSubscription: Bool + public let teamCreated: Bool + public let role: String? + public let isTeamAdmin: Bool? + public let seatsPurchased: Int? + public let seatsUsed: Int? + public let seatsAvailable: Int? + public let membersCount: Int? + public let billedSeatCount: Int? + public let teamMemberCount: Int? + public let seatLimitExceeded: Bool? + + public var memberCount: Int? { teamMemberCount ?? membersCount ?? seatsUsed } + public var paidSeatCount: Int? { billedSeatCount ?? seatsPurchased } + public var availableSeatCount: Int? { seatsAvailable.map { max(0, $0) } } + public var seatMismatch: TeamSeatMismatch? { + guard teamCreated else { return nil } + let exactMismatch = memberCount.flatMap { members in paidSeatCount.map { members > $0 } } ?? false + guard exactMismatch || seatLimitExceeded == true else { return nil } + return TeamSeatMismatch(memberCount: memberCount, billedSeatCount: paidSeatCount) + } + + func validate() throws { + guard + [seatsPurchased, seatsUsed, membersCount, billedSeatCount, teamMemberCount] + .allSatisfy({ $0.map { $0 >= 0 } ?? true }) + else { throw AccountServiceError.invalidResponse } + } +} + +public protocol BillingServing: Sendable { + func subscriptionStatus() async throws -> BillingStatus + func teamStatus() async throws -> TeamStatus + func retire() async +} + +public protocol BillingTokenSource: Sendable { + func mintToken(audience: String) async throws -> String +} + +public struct BillingHTTPResponse: Sendable { + public let status: Int + public let body: Data + public init(status: Int, body: Data) { self.status = status; self.body = body } +} + +public protocol BillingHTTP: Sendable { + func get(url: URL, bearerToken: String) async throws -> BillingHTTPResponse +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Account/BillingService.swift b/apps/maple-ios/Sources/MapleAppCore/Account/BillingService.swift new file mode 100644 index 000000000..b77c2246b --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Account/BillingService.swift @@ -0,0 +1,185 @@ +import Foundation +import MapleSDK + +/// An account generation owns this entire cache, including in-flight minting. +/// Create a new instance on every sign-in, even for the same principal. +public actor BillingService: BillingServing { + public nonisolated let scope: AccountScope + private let baseURL: URL + private let source: any BillingTokenSource + private let http: any BillingHTTP + private var active = true + private var token: String? + private var mint: (id: UUID, task: Task)? + + public init( + scope: AccountScope, configuration: AppConfiguration, + tokenSource: any BillingTokenSource, http: any BillingHTTP = URLSessionBillingHTTP() + ) { + self.scope = scope + self.baseURL = configuration.billingURL + self.source = tokenSource + self.http = http + } + + public func subscriptionStatus() async throws -> BillingStatus { + let value: BillingStatus = try await fetch(path: "v1/maple/subscription/status") + try value.validate() + return value + } + + public func teamStatus() async throws -> TeamStatus { + let value: TeamStatus = try await fetch(path: "v1/maple/team/status") + try value.validate() + return value + } + + public func retire() { + active = false + token = nil + mint?.task.cancel() + mint = nil + } + + private func checkOwner() throws { + guard active else { throw AccountServiceError.sessionChanged } + if Task.isCancelled { throw AccountServiceError.cancelled } + } + + private func currentToken() async throws -> String { + try checkOwner() + if let token { return token } + let request: (id: UUID, task: Task) + if let mint { + request = mint + } else { + let audience = baseURL.absoluteString + let source = source + request = (UUID(), Task { try await source.mintToken(audience: audience) }) + mint = request + } + do { + let result = try await request.task.value + try checkOwner() + guard !result.isEmpty, result.utf8.count <= 65_536, + result.utf8.allSatisfy({ (33...126).contains($0) }) + else { throw AccountServiceError.invalidResponse } + // A waiter for an older mint must never replace a newer cache entry. + if mint?.id == request.id { + token = result + mint = nil + } + return token ?? result + } catch { + if mint?.id == request.id { mint = nil } + try checkOwner() + throw Self.safe(error) + } + } + + private func fetch(path: String) async throws -> Value { + do { + var usedToken = try await currentToken() + for attempt in 0..<2 { + try checkOwner() + let response = try await http.get(url: baseURL.appendingPathComponent(path), bearerToken: usedToken) + try checkOwner() + if response.status == 401 { + guard attempt == 0 else { throw AccountServiceError.unauthorized } + // Another request may have already replaced this exact token. + if token == usedToken { token = nil } + usedToken = try await currentToken() + continue + } + guard (200..<300).contains(response.status) else { throw AccountServiceError.unavailable } + guard response.body.count <= URLSessionBillingHTTP.maximumBody else { + throw AccountServiceError.invalidResponse + } + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return try decoder.decode(Value.self, from: response.body) + } + throw AccountServiceError.unauthorized + } catch { + try checkOwner() + throw Self.safe(error) + } + } + + static func safe(_ error: any Error) -> AccountServiceError { + if let error = error as? AccountServiceError { return error } + if error is CancellationError { return .cancelled } + if error is DecodingError { return .invalidResponse } + return .unavailable + } +} + +public struct SDKBillingTokenSource: BillingTokenSource { + private let api: ResearchAPI + public init(api: ResearchAPI) { self.api = api } + public func mintToken(audience: String) async throws -> String { + try await api.generateThirdPartyToken(audience: audience).token + } +} + +public protocol ModelCatalogServing: Sendable { + func fetchCatalog() async throws -> ModelCatalog +} + +public struct SDKModelCatalogSource: ModelCatalogServing { + private let api: ResearchAPI + public init(api: ResearchAPI) { self.api = api } + public func fetchCatalog() async throws -> ModelCatalog { try await api.fetchModelCatalog() } +} + +/// No redirects, cookies, persistence or credential storage at this boundary. +/// The bounded streaming read prevents a billing error body consuming unbounded memory. +public final class URLSessionBillingHTTP: BillingHTTP, Sendable { + static let maximumBody = 1_048_576 + + public init() {} + + public func get(url: URL, bearerToken: String) async throws -> BillingHTTPResponse { + let configuration = URLSessionConfiguration.ephemeral + configuration.httpCookieStorage = nil + configuration.httpShouldSetCookies = false + configuration.urlCache = nil + configuration.urlCredentialStorage = nil + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + configuration.timeoutIntervalForRequest = 15 + configuration.timeoutIntervalForResource = 30 + let delegate = RejectBillingRedirects() + let session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil) + // In particular, cancel an unread error or oversized body without + // interrupting any concurrent account request's session. + defer { session.invalidateAndCancel() } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + let (bytes, response) = try await session.bytes(for: request) + guard let response = response as? HTTPURLResponse, response.url == url else { + throw AccountServiceError.invalidResponse + } + guard (200..<300).contains(response.statusCode) else { + return BillingHTTPResponse(status: response.statusCode, body: Data()) + } + guard response.expectedContentLength <= Self.maximumBody else { throw AccountServiceError.invalidResponse } + var body = Data() + for try await byte in bytes { + guard body.count < Self.maximumBody else { throw AccountServiceError.invalidResponse } + body.append(byte) + } + return BillingHTTPResponse(status: response.statusCode, body: body) + } +} + +private final class RejectBillingRedirects: NSObject, URLSessionTaskDelegate, Sendable { + func urlSession( + _ session: URLSession, task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, + completionHandler: @escaping @Sendable (URLRequest?) -> Void + ) { + completionHandler(nil) + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/AccountScope.swift b/apps/maple-ios/Sources/MapleAppCore/AccountScope.swift new file mode 100644 index 000000000..dec05c4cf --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/AccountScope.swift @@ -0,0 +1,15 @@ +import Foundation + +/// A principal namespace plus a revocable app-session lease. Signing into the +/// same principal again creates a new generation; old work cannot adopt it. +public struct AccountScope: Codable, Hashable, Sendable { + public let apiOrigin: String + public let principalID: String + public let generation: UUID + + public init(apiOrigin: String, principalID: String, generation: UUID = UUID()) { + self.apiOrigin = apiOrigin + self.principalID = principalID + self.generation = generation + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/AppConfiguration.swift b/apps/maple-ios/Sources/MapleAppCore/AppConfiguration.swift new file mode 100644 index 000000000..dd9969c9c --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/AppConfiguration.swift @@ -0,0 +1,88 @@ +import Foundation +import MapleSDK + +public enum ConfigurationError: Error, Equatable, Sendable { + case missingValue, invalidProfile, invalidOrigin, invalidClient, invalidCallback +} + +public struct AppConfiguration: Equatable, Sendable { + public enum Profile: String, Sendable { case local, development, production } + + public let profile: Profile + public let apiURL: URL + public let billingURL: URL + public let flagsURL: URL + public let authURL: URL + public let clientID: String + public let callbackScheme: String + + public let trustPolicy: PCRTrustPolicy + + public static func == (lhs: Self, rhs: Self) -> Bool { + // The trust policy is derived exclusively from this immutable profile. + lhs.profile == rhs.profile && lhs.apiURL == rhs.apiURL && lhs.billingURL == rhs.billingURL + && lhs.flagsURL == rhs.flagsURL && lhs.authURL == rhs.authURL + && lhs.clientID == rhs.clientID && lhs.callbackScheme == rhs.callbackScheme + } + + public init( + profile: Profile, apiURL: String, billingURL: String, flagsURL: String, + authURL: String, clientID: String, callbackScheme: String + ) throws { + self.profile = profile + self.trustPolicy = try PCRTrustPolicy( + environment: profile == .production ? .production : .development, + remoteHistoryEnabled: profile != .local) + self.apiURL = try Self.origin(apiURL, profile: profile) + self.billingURL = try Self.origin(billingURL, profile: profile) + self.flagsURL = try Self.origin(flagsURL, profile: profile) + self.authURL = try Self.origin(authURL, profile: profile) + guard UUID(uuidString: clientID) != nil else { throw ConfigurationError.invalidClient } + self.clientID = clientID.lowercased() + guard callbackScheme.count <= 128, callbackScheme.hasPrefix("cloud.opensecret.maple"), + callbackScheme.first?.isASCII == true, + callbackScheme.unicodeScalars.allSatisfy({ + CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789+.-").contains($0) + }), callbackScheme != "http", callbackScheme != "https" + else { throw ConfigurationError.invalidCallback } + self.callbackScheme = callbackScheme + } + + public static func load(bundle: Bundle = .main) throws -> AppConfiguration { + func value(_ key: String) throws -> String { + guard let value = bundle.object(forInfoDictionaryKey: key) as? String, + !value.isEmpty, !value.contains("$(") + else { throw ConfigurationError.missingValue } + return value + } + guard let profile = Profile(rawValue: try value("MAPLE_PROFILE")) else { + throw ConfigurationError.invalidProfile + } + return try AppConfiguration( + profile: profile, apiURL: value("MAPLE_API_URL"), billingURL: value("MAPLE_BILLING_URL"), + flagsURL: value("MAPLE_FLAGS_URL"), authURL: value("MAPLE_AUTH_URL"), + clientID: value("MAPLE_CLIENT_ID"), callbackScheme: value("MAPLE_CALLBACK_SCHEME")) + } + + private static func origin(_ value: String, profile: Profile) throws -> URL { + guard value.utf8.count <= 2_048, !value.contains("\\"), + value == value.trimmingCharacters(in: .whitespacesAndNewlines), + var parts = URLComponents(string: value), let host = parts.host, !host.isEmpty, + parts.user == nil, parts.password == nil, parts.query == nil, parts.fragment == nil, + parts.path.isEmpty || parts.path == "/", parts.percentEncodedHost == host, + parts.port.map({ 1...65_535 ~= $0 }) ?? true + else { throw ConfigurationError.invalidOrigin } + if profile == .local { + guard parts.scheme == "http", ["127.0.0.1", "localhost", "[::1]", "::1"].contains(host), + parts.port != nil + else { throw ConfigurationError.invalidOrigin } + } else { + guard parts.scheme == "https" else { throw ConfigurationError.invalidOrigin } + } + parts.scheme = parts.scheme?.lowercased() + parts.host = host.lowercased() + parts.path = "" + guard let result = parts.url else { throw ConfigurationError.invalidOrigin } + return result + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Audio/DictationSendAssociation.swift b/apps/maple-ios/Sources/MapleAppCore/Audio/DictationSendAssociation.swift new file mode 100644 index 000000000..40dd33e62 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Audio/DictationSendAssociation.swift @@ -0,0 +1,69 @@ +import Foundation + +/// Stable draft ownership survives chat assignment and navigation. A queue +/// editor has its own draft ID, distinct from its held composer and original row. +public struct DictationDraftOwner: Equatable, Sendable { + public let scope: AccountScope + public let storageOwnerID: UUID + public let draftID: UUID + public init(scope: AccountScope, storageOwnerID: UUID, draftID: UUID) { + self.scope = scope; self.storageOwnerID = storageOwnerID; self.draftID = draftID + } +} + +/// Binds the narrow Send-after-dictation transition to its original draft and +/// one durable accepted turn. It retains identifiers only, never draft text. +public struct DictationSendAssociation: Sendable { + public struct Ticket: Equatable, Sendable { + fileprivate let owner: DictationDraftOwner + fileprivate let epoch: UUID + } + public struct Arm: Equatable, Sendable { + public let chatID: String + public let messageID: String + } + private var review: Ticket? + private var submitted: Ticket? + private var awaitingConversation: (ticket: Ticket, messageID: String)? + public init() {} + + public mutating func captureAdmitted(_ owner: DictationDraftOwner) { + reset() + review = Ticket(owner: owner, epoch: UUID()) + } + public func ticket(for owner: DictationDraftOwner, reviewActive: Bool) -> Ticket? { + guard reviewActive, review?.owner == owner else { return nil } + return review + } + /// Call only after explicit Send returned true. Persistence acceptance remains + /// a separate callback, so a failed save cannot arm automatic replacement. + public mutating func didSubmit(_ ticket: Ticket) { + guard review == ticket else { return } + submitted = ticket + } + public mutating func accepted(_ value: ChatSendAcceptance, reviewActive: Bool) -> Arm? { + let owner = DictationDraftOwner( + scope: value.scope, storageOwnerID: value.storageOwnerID, draftID: value.draftID) + guard reviewActive, let ticket = submitted, review == ticket, ticket.owner == owner else { return nil } + submitted = nil + if let chatID = value.conversationID { + reset() + return Arm(chatID: chatID, messageID: value.messageID) + } + awaitingConversation = (ticket, value.messageID) + return nil + } + public mutating func conversationAssigned( + scope: AccountScope, storageOwnerID: UUID, chatID: String, reviewActive: Bool + ) -> Arm? { + guard reviewActive, let pending = awaitingConversation, review == pending.ticket, + pending.ticket.owner.scope == scope, pending.ticket.owner.storageOwnerID == storageOwnerID + else { return nil } + reset() + return Arm(chatID: chatID, messageID: pending.messageID) + } + public mutating func retire(_ owner: DictationDraftOwner) { + if review?.owner == owner { reset() } + } + public mutating func reset() { review = nil; submitted = nil; awaitingConversation = nil } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Audio/ReplyListeningSession.swift b/apps/maple-ios/Sources/MapleAppCore/Audio/ReplyListeningSession.swift new file mode 100644 index 000000000..15d8fce36 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Audio/ReplyListeningSession.swift @@ -0,0 +1,349 @@ +import Foundation +import Observation + +@MainActor @Observable public final class ReplyListeningSession { + public enum Phase: Equatable, Sendable { case closed, preparing, playing, paused, ended, failed(SpeechIssue) } + public enum DictationHold: Equatable, Sendable { case none, capture, transcription, review } + public let scope: AccountScope + public private(set) var phase: Phase = .closed + public private(set) var current: SpeechReply? + public private(set) var pending: [SpeechReply] = [] + public private(set) var speed: MaplePlaybackSpeed + public private(set) var voice: MapleVoice + public private(set) var manualPaused = false + public private(set) var routePaused = false + public private(set) var dictationHold: DictationHold = .none + public private(set) var issue: SpeechIssue? + public private(set) var cleanupRequired = false + public private(set) var position: TimeInterval = 0 + public private(set) var duration: TimeInterval = 0 + public private(set) var autoplayChats = Set() + public var onSpeedChanged: (@MainActor (MaplePlaybackSpeed) -> Void)? + @ObservationIgnored private let preparer: any SpeechPreparing + @ObservationIgnored private let player: any SpeechPlaying + @ObservationIgnored private let admission: @MainActor () -> SpeechIssue? + @ObservationIgnored private let makeID: @Sendable () -> UUID + @ObservationIgnored private var valid = true + @ObservationIgnored private var visibleChat: String? + @ObservationIgnored private var foreground = true + // Presentation and transport controls observe this owner even while closed. + private var owner: SpeechOwner? + @ObservationIgnored private var prepared: PreparedSpeech? + @ObservationIgnored private var operation: UUID + @ObservationIgnored private var playbackID: UUID + @ObservationIgnored private var preparing: Task? + @ObservationIgnored private var cleanup: Task? + @ObservationIgnored private var retiredOwners: [SpeechOwner] = [] + @ObservationIgnored private var seenResponses: [String] = [] + @ObservationIgnored private var explicitStart = false + @ObservationIgnored private var dictationSend: (chatID: String, turnID: String)? + @ObservationIgnored private var suppressOldAdvance = false + @ObservationIgnored private var interruptionMayResume = false + + public init( + scope: AccountScope, preparer: any SpeechPreparing, player: any SpeechPlaying, + voice: MapleVoice = .casualFemale, speed: MaplePlaybackSpeed = .normal, + admission: @escaping @MainActor () -> SpeechIssue? = { nil }, + makeID: @escaping @Sendable () -> UUID = UUID.init + ) { + self.scope = scope; self.preparer = preparer; self.player = player; self.voice = voice; self.speed = speed + self.admission = admission; self.makeID = makeID; operation = makeID(); playbackID = makeID() + player.onEvent = { [weak self] event in self?.receive(event) } + player.setSpeed(speed) + } + + public var isPresented: Bool { owner != nil } + public var sessionID: UUID? { owner?.sessionID } + public var canPlay: Bool { valid && owner != nil && dictationHold != .capture && dictationHold != .transcription } + public var pendingCount: Int { pending.count } + public func autoplayEnabled(chatID: String) -> Bool { autoplayChats.contains(chatID) } + + /// Visibility changes never start audio or collect completions missed elsewhere. + public func setVisibility(chatID: String?, foreground: Bool) { + visibleChat = chatID; self.foreground = foreground + } + public func setAutoplay(_ enabled: Bool, chatID: String) { + guard valid else { return } + if enabled, let issue = admission() { self.issue = issue; return } + if enabled { autoplayChats.insert(chatID) } else { autoplayChats.remove(chatID) } + } + public func updatePreferences(voice: MapleVoice, speed: MaplePlaybackSpeed) { + guard valid else { return }; self.voice = voice; self.speed = speed; player.setSpeed(speed) + } + public func setSpeed(_ speed: MaplePlaybackSpeed) { + guard valid else { return }; self.speed = speed; player.setSpeed(speed); onSpeedChanged?(speed) + } + + public func completed(_ completion: ChatCompletion) { + guard completion.scope == scope else { return } + completed( + SpeechReply( + chatID: completion.conversationID, + responseID: completion.responseID ?? completion.runID.uuidString, + text: completion.assistantText, turnID: completion.messageID), + wasVisible: completion.wasVisible, wasForeground: completion.wasForeground, + recovered: completion.recovered, wasStopped: completion.wasStopped) + } + + /// Call only for a completed assistant reply, carrying its captured completion + /// visibility. Recovered/stopped replies are never eligible for automatic speech. + public func completed( + _ reply: SpeechReply, wasVisible: Bool, wasForeground: Bool, + recovered: Bool = false, wasStopped: Bool = false + ) { + guard valid, !recovered, !wasStopped, wasVisible, wasForeground, + foreground, visibleChat == reply.chatID, autoplayChats.contains(reply.chatID), + !seenResponses.contains(reply.responseID) + else { return } + if let issue = admission() { self.issue = issue; return } + guard admissible(reply) else { issue = .capacity; return } + if let arm = dictationSend, arm.chatID == reply.chatID, arm.turnID == reply.turnID, + !manualPaused, !routePaused, dictationHold == .none + { + dictationSend = nil; suppressOldAdvance = false + replace(with: reply, explicit: false); return + } + remember(reply) + if owner == nil { + if !manualPaused && !routePaused && dictationHold == .none { + replace(with: reply, explicit: false) + } else { + owner = SpeechOwner(account: scope, sessionID: makeID()) + pending.append(reply); phase = .ended + } + return + } + guard pending.count < SpeechLimits.maximumPendingReplies, + pending.reduce(0, { $0 + $1.text.utf8.count }) + reply.text.utf8.count + <= SpeechLimits.maximumPendingTextBytes + else { issue = .capacity; return } + pending.append(reply) + if phase == .ended { advanceAutomatically() } + } + + /// Speaker actions are explicit replacement, and do not enable autoplay. + public func playReply(_ reply: SpeechReply) { + guard valid else { return } + guard dictationHold != .capture && dictationHold != .transcription else { issue = .dictationActive; return } + if let issue = admission() { self.issue = issue; return } + guard admissible(reply) else { issue = .capacity; return } + manualPaused = false; routePaused = false; dictationHold = .none + dictationSend = nil; suppressOldAdvance = false + replace(with: reply, explicit: true) + } + + @discardableResult public func play() -> Bool { + guard canPlay else { return false } + manualPaused = false; routePaused = false; dictationHold = .none + dictationSend = nil; suppressOldAdvance = false; issue = nil + if phase == .failed(.storage), let current { replace(with: current, explicit: true); return true } + if phase == .preparing { explicitStart = true; return true } + if phase == .ended { + guard !pending.isEmpty else { return false } + if let issue = admission() { self.issue = issue; return false } + prepare(pending.removeFirst(), explicit: true); return true + } + if let prepared, duration > 0 { + _ = prepared + do { phase = .playing; try player.play(); return true } catch { + phase = .failed(.invalidAudio); issue = .invalidAudio; return false + } + } + if let current { prepare(current, explicit: true); return true } + return false + } + + public func pause() { + guard valid, owner != nil else { return } + manualPaused = true; explicitStart = false; player.pause(); refreshPosition() + interruptionMayResume = false + if phase != .preparing && phase != .ended { phase = .paused } + } + public func routeDisconnected() { + guard valid else { return } + routePaused = true; explicitStart = false; player.pause(); refreshPosition() + interruptionMayResume = false + if owner != nil && phase != .preparing && phase != .ended { phase = .paused } + } + /// A route appearing never clears route pause; only a genuine explicit Play does. + public func routeReconnected() {} + public func interruptionBegan() { + let wasPlaying = phase == .playing && !manualPaused + routeDisconnected(); interruptionMayResume = wasPlaying + } + public func interruptionEnded(shouldResume: Bool) { + let resume = interruptionMayResume + interruptionMayResume = false + guard valid, resume, shouldResume, !manualPaused, dictationHold == .none, + phase == .paused, prepared != nil + else { return } + routePaused = false + do { phase = .playing; try player.play() } catch { phase = .failed(.invalidAudio); issue = .invalidAudio } + } + + public func startDictation() { + guard valid else { return } + dictationHold = .capture; dictationSend = nil; suppressOldAdvance = true + interruptionMayResume = false + explicitStart = false; player.pause(); refreshPosition() + if owner != nil && phase != .preparing && phase != .ended { phase = .paused } + } + public func transcriptionStarted() { + guard valid else { return } + startDictation(); dictationHold = .transcription + } + public func dictationFinishedOrCancelled() { + guard valid, dictationHold == .capture || dictationHold == .transcription else { return } + dictationHold = .review + } + /// Sending arms only this exact turn. It does not resume old audio. If review + /// was the sole pause, its new eligible completion may replace the old session. + /// Manual/route pause instead preserve the existing pending FIFO. + public func sentAfterDictation(chatID: String, turnID: String) { + guard valid, dictationHold == .review else { return } + dictationHold = .none + if !manualPaused && !routePaused { dictationSend = (chatID, turnID) } + } + + public func skip(seconds: TimeInterval) { + guard valid, prepared != nil, seconds.isFinite else { return } + let ticket = playbackID + do { + try player.seek(to: min(duration, max(0, player.position + seconds))) + guard playbackID == ticket else { return } + refreshPosition() + if position >= duration { phase = .ended } else if phase == .ended { phase = .paused } + } catch { issue = .invalidAudio } + } + public func refreshPosition() { + guard valid else { return } + position = min(duration, max(0, player.position.isFinite ? player.position : 0)) + } + @discardableResult public func remote(_ command: SpeechRemoteCommand, sessionID: UUID) -> Bool { + guard valid, owner?.sessionID == sessionID else { return false } + switch command { + case .play: return play() + case .pause: pause(); return true + case .toggle: if phase == .playing { pause(); return true }; return play() + case .backwardTenSeconds: skip(seconds: -10); return prepared != nil + case .forwardTenSeconds: skip(seconds: 10); return prepared != nil + } + } + + public func close() { + guard valid else { return } + if let chat = current?.chatID ?? pending.first?.chatID { autoplayChats.remove(chat) } + endSession() + manualPaused = false; routePaused = false + // Closing the player does not stop a separately owned microphone operation. + } + public func invalidate() async throws { + guard valid else { try await retryCleanup(); return } + valid = false; autoplayChats = []; onSpeedChanged = nil + endSession(); player.onEvent = nil + try await cleanup?.value + } + public func waitForPreparation() async { await preparing?.value } + public func waitForCleanup() async throws { try await cleanup?.value } + public func retryCleanup() async throws { + scheduleCleanup() + try await cleanup?.value + } + + private func admissible(_ reply: SpeechReply) -> Bool { + !reply.chatID.isEmpty && !reply.responseID.isEmpty && reply.text.utf8.count <= SpeechLimits.maximumSourceBytes + } + private func remember(_ reply: SpeechReply) { + seenResponses.append(reply.responseID) + if seenResponses.count > 256 { seenResponses.removeFirst(seenResponses.count - 256) } + } + private var mayAutomaticallyStart: Bool { + valid && !manualPaused && !routePaused && dictationHold == .none && !suppressOldAdvance + } + private func advanceAutomatically() { + guard mayAutomaticallyStart, let next = pending.first, foreground, + visibleChat == next.chatID, autoplayChats.contains(next.chatID) + else { return } + if let issue = admission() { self.issue = issue; return } + prepare(pending.removeFirst(), explicit: false) + } + private func replace(with reply: SpeechReply, explicit: Bool) { + endSession() + owner = SpeechOwner(account: scope, sessionID: makeID()); remember(reply) + prepare(reply, explicit: explicit) + } + private func prepare(_ reply: SpeechReply, explicit: Bool) { + guard let owner, valid else { return } + if let issue = admission() { self.issue = issue; if prepared == nil { phase = .failed(issue) }; return } + let oldPrepared = prepared + preparing?.cancel(); player.stop(); prepared = nil + let ticket = makeID(); operation = ticket; playbackID = makeID(); let playback = playbackID + current = reply; phase = .preparing; issue = nil; position = 0; duration = 0; explicitStart = explicit + let previousCleanup = cleanup, chosenVoice = voice + preparing = Task { [weak self, preparer] in + do { + try await previousCleanup?.value + if let oldPrepared { try await preparer.discard(oldPrepared) } + try Task.checkCancellation() + let audio = try await preparer.prepare(reply, voice: chosenVoice, owner: owner) + guard let self, self.valid, self.operation == ticket else { + try await preparer.discard(audio); return + } + self.prepared = audio + try await self.player.load(audio, playbackID: playback) + guard self.valid, self.operation == ticket else { return } + self.duration = audio.duration + self.player.setSpeed(self.speed) + let permitted = + !self.manualPaused && !self.routePaused && self.dictationHold == .none + && (self.explicitStart + || (self.mayAutomaticallyStart && self.foreground + && self.visibleChat == reply.chatID && self.autoplayChats.contains(reply.chatID))) + if permitted { self.phase = .playing; try self.player.play() } else { self.phase = .paused } + } catch { + guard let self, self.valid, self.operation == ticket else { return } + let issue = (error as? SpeechIssue) ?? .unavailable + self.issue = issue; self.phase = .failed(issue) + } + } + } + private func receive(_ event: SpeechPlayerEvent) { + guard valid, owner != nil else { return } + switch event { + case .ended(let id): + guard id == playbackID else { return } + position = duration; phase = .ended; advanceAutomatically() + case .failed(let id): + guard id == playbackID else { return } + issue = .invalidAudio; phase = .failed(.invalidAudio) + } + } + private func endSession() { + operation = makeID(); playbackID = makeID(); preparing?.cancel(); preparing = nil; player.stop() + if let owner { + retiredOwners.append(owner) + scheduleCleanup() + } + owner = nil; prepared = nil; current = nil; pending = []; seenResponses = [] + phase = .closed; position = 0; duration = 0; issue = nil + dictationSend = nil; suppressOldAdvance = false; explicitStart = false + interruptionMayResume = false + } + private func scheduleCleanup() { + guard !retiredOwners.isEmpty else { return } + let oldCleanup = cleanup, retiring = retiredOwners + cleanup = Task { [weak self, preparer] in + // Retry exact retained owners if prior cleanup failed. No new + // preparation is admitted until the resulting task succeeds. + _ = try? await oldCleanup?.value + do { + for owner in retiring { try await preparer.retire(owner) } + self?.retiredOwners.removeAll { retiring.contains($0) } + if let self { self.cleanupRequired = !self.retiredOwners.isEmpty } + } catch { + self?.cleanupRequired = true + throw SpeechIssue.storage + } + } + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Audio/SpeechContracts.swift b/apps/maple-ios/Sources/MapleAppCore/Audio/SpeechContracts.swift new file mode 100644 index 000000000..351f2739d --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Audio/SpeechContracts.swift @@ -0,0 +1,102 @@ +import Foundation +import MapleSDK + +public enum SpeechIssue: Error, Equatable, Sendable { + case emptyText, capacity, invalidAudio, unavailable, storage, retired, dictationActive + case paidPlanRequired, usageLimit +} + +public struct SpeechOwner: Hashable, Sendable { + public let account: AccountScope + public let sessionID: UUID + public init(account: AccountScope, sessionID: UUID = UUID()) { self.account = account; self.sessionID = sessionID } +} + +public struct SpeechReply: Equatable, Sendable { + public let chatID: String + public let responseID: String + public let text: String + public let turnID: String? + public init(chatID: String, responseID: String, text: String, turnID: String? = nil) { + self.chatID = chatID; self.responseID = responseID; self.text = text; self.turnID = turnID + } +} + +public struct StoredSpeechFile: Hashable, Sendable { + public let id: UUID + public let url: URL + public let byteCount: Int + public init(id: UUID, url: URL, byteCount: Int) { self.id = id; self.url = url; self.byteCount = byteCount } +} + +public struct SpeechChunk: Equatable, Sendable { + public let file: StoredSpeechFile + public let duration: TimeInterval + public init(file: StoredSpeechFile, duration: TimeInterval) { self.file = file; self.duration = duration } +} + +public struct PreparedSpeech: Equatable, Sendable { + public let owner: SpeechOwner + public let responseID: String + public let chunks: [SpeechChunk] + public var duration: TimeInterval { chunks.reduce(0) { $0 + $1.duration } } + public init(owner: SpeechOwner, responseID: String, chunks: [SpeechChunk]) { + self.owner = owner; self.responseID = responseID; self.chunks = chunks + } +} + +public protocol SpeechFileStoring: Sendable { + func startupCleanup() async throws + func activate(_ owner: SpeechOwner) async throws + func write(_ body: AsyncThrowingStream, owner: SpeechOwner) async throws -> StoredSpeechFile + func remove(_ files: [StoredSpeechFile], owner: SpeechOwner) async throws + func retire(_ owner: SpeechOwner) async throws +} + +public protocol SpeechMediaInspecting: Sendable { + func duration(of file: StoredSpeechFile) async throws -> TimeInterval +} + +public protocol SpeechSynthesizing: Sendable { + func synthesize(text: String, voice: MapleVoice) async throws -> MapleResponse +} + +public struct SDKSpeechSynthesizer: SpeechSynthesizing { + private let api: ResearchAPI + public init(api: ResearchAPI) { self.api = api } + public func synthesize(text: String, voice: MapleVoice) async throws -> MapleResponse { + try await api.synthesizeSpeech( + input: text, voice: voice.rawValue, speed: 1, model: "voxtral-tts", format: "wav") + } +} + +public protocol SpeechPreparing: Sendable { + func prepare(_ reply: SpeechReply, voice: MapleVoice, owner: SpeechOwner) async throws -> PreparedSpeech + func discard(_ speech: PreparedSpeech) async throws + func retire(_ owner: SpeechOwner) async throws +} + +public enum SpeechPlayerEvent: Sendable { case ended(UUID), failed(UUID) } +public enum SpeechRemoteCommand: Sendable { case play, pause, toggle, backwardTenSeconds, forwardTenSeconds } + +@MainActor public protocol SpeechPlaying: AnyObject { + var onEvent: (@MainActor (SpeechPlayerEvent) -> Void)? { get set } + var position: TimeInterval { get } + var duration: TimeInterval { get } + func load(_ speech: PreparedSpeech, playbackID: UUID) async throws + func play() throws + func pause() + func stop() + func seek(to position: TimeInterval) throws + func setSpeed(_ speed: MaplePlaybackSpeed) +} + +public enum SpeechLimits { + public static let maximumSourceBytes = 1_024 * 1_024 + public static let maximumChunks = 256 + public static let maximumPendingReplies = 16 + public static let maximumPendingTextBytes = 4 * 1_024 * 1_024 + public static let maximumFileBytes = 32 * 1_024 * 1_024 + public static let maximumSessionBytes = 256 * 1_024 * 1_024 + public static let maximumStreamBlockBytes = 128 * 1_024 +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Audio/SpeechPreparer.swift b/apps/maple-ios/Sources/MapleAppCore/Audio/SpeechPreparer.swift new file mode 100644 index 000000000..328631d78 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Audio/SpeechPreparer.swift @@ -0,0 +1,79 @@ +import Foundation +import MapleSDK + +/// Prepares every chunk before exposing Play. No provider request or file is +/// reused across a retired listening session, including same-principal relogin. +public actor SpeechPreparer: SpeechPreparing { + private let synthesis: any SpeechSynthesizing + private let files: any SpeechFileStoring + private let inspector: any SpeechMediaInspecting + private let admission: @Sendable () async -> SpeechIssue? + private var retired = Set() + + public init( + synthesis: any SpeechSynthesizing, files: any SpeechFileStoring, inspector: any SpeechMediaInspecting, + admission: @escaping @Sendable () async -> SpeechIssue? = { nil } + ) { + self.synthesis = synthesis; self.files = files; self.inspector = inspector; self.admission = admission + } + + public func prepare(_ reply: SpeechReply, voice: MapleVoice, owner: SpeechOwner) async throws -> PreparedSpeech { + try require(owner) + let planning = Task.detached(priority: .userInitiated) { try SpeechTextPlanner.chunks(reply.text) } + let text = try await withTaskCancellationHandler { + try await planning.value + } onCancel: { + planning.cancel() + } + try require(owner) + guard !text.isEmpty else { throw SpeechIssue.emptyText } + try await files.activate(owner) + var created: [StoredSpeechFile] = [], chunks: [SpeechChunk] = [] + do { + for part in text { + try require(owner) + if let issue = await admission() { throw issue } + try require(owner) + let response = try await synthesis.synthesize(text: part, voice: voice) + try require(owner) + let mime = + response.header("content-type")?.split(separator: ";", maxSplits: 1).first? + .trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + guard (200...299).contains(response.status), !mime.contains("json"), + mime.hasPrefix("audio/") || mime == "application/octet-stream" + else { throw SpeechIssue.invalidAudio } + let file = try await files.write(response.body, owner: owner) + created.append(file) + try require(owner) + let duration = try await inspector.duration(of: file) + try require(owner) + guard duration.isFinite, duration > 0 else { throw SpeechIssue.invalidAudio } + chunks.append(SpeechChunk(file: file, duration: duration)) + } + try require(owner) + let prepared = PreparedSpeech(owner: owner, responseID: reply.responseID, chunks: chunks) + guard prepared.duration.isFinite, prepared.duration > 0 else { throw SpeechIssue.invalidAudio } + return prepared + } catch { + do { try await files.remove(created, owner: owner) } catch { throw SpeechIssue.storage } + throw Self.present(error) + } + } + + public func discard(_ speech: PreparedSpeech) async throws { + try await files.remove(speech.chunks.map(\.file), owner: speech.owner) + } + public func retire(_ owner: SpeechOwner) async throws { + retired.insert(owner) + try await files.retire(owner) + } + private func require(_ owner: SpeechOwner) throws { + guard !retired.contains(owner) else { throw SpeechIssue.retired } + try Task.checkCancellation() + } + private static func present(_ error: any Error) -> SpeechIssue { + if let issue = error as? SpeechIssue { return issue } + if case let ResearchAPIError.http(_, reason) = error, reason == .usageLimit { return .usageLimit } + return .unavailable + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Audio/SpeechTextPlanner.swift b/apps/maple-ios/Sources/MapleAppCore/Audio/SpeechTextPlanner.swift new file mode 100644 index 000000000..59aacf889 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Audio/SpeechTextPlanner.swift @@ -0,0 +1,196 @@ +import Foundation + +/// Bounded native equivalent of the Research speech text preparation rules. +/// Run this synchronous parser away from the main actor for real replies. +public enum SpeechTextPlanner { + public static func chunks(_ text: String, maximumWords: Int = 300) throws -> [String] { + guard (1...300).contains(maximumWords) else { return [] } + let sanitized = try sanitize(text) + guard !sanitized.isEmpty else { return [] } + var chunks: [String] = [], current = "", count = 0 + func flush() { + guard !current.isEmpty else { return } + chunks.append(normalize(current)); current = ""; count = 0 + } + func wordPieces(_ word: String) throws -> [String] { + var result: [String] = [], piece = "", scalars = 0 + for character in word { + let size = character.unicodeScalars.count + guard size <= 1_000 else { throw SpeechIssue.capacity } + if scalars + size > 1_000 { result.append(piece); piece = ""; scalars = 0 } + piece.append(character); scalars += size + } + if !piece.isEmpty { result.append(piece) } + return result + } + func push(_ unit: String) throws { + let words = unit.split(whereSeparator: \.isWhitespace).map(String.init) + guard !words.isEmpty else { return } + if words.count > maximumWords || words.contains(where: { $0.unicodeScalars.count > 1_000 }) { + flush() + for word in words { + if word.unicodeScalars.count > 1_000 { + flush(); chunks += try wordPieces(word).map(normalize) + } else { + if count == maximumWords + || current.unicodeScalars.count + word.unicodeScalars.count + 2 > 100_000 + { + flush() + } + current += current.isEmpty ? word : " " + word; count += 1 + } + } + flush(); return + } + let joined = words.joined(separator: " ") + if count + words.count > maximumWords + || current.unicodeScalars.count + joined.unicodeScalars.count + 2 > 100_000 + { + flush() + } + // Many individually legal tokens can exceed a provider request cap. + if joined.unicodeScalars.count + 1 > 100_000 { + for word in words { + if count == maximumWords || current.unicodeScalars.count + word.unicodeScalars.count + 2 > 100_000 { + flush() + } + current += current.isEmpty ? word : " " + word; count += 1 + } + flush(); return + } + current += current.isEmpty ? joined : " " + joined; count += words.count + } + for paragraph in sanitized.components(separatedBy: "\n\n") { + try Task.checkCancellation() + if paragraph.split(whereSeparator: \.isWhitespace).count <= maximumWords { + try push(paragraph) + } else { + let regex = try NSRegularExpression(pattern: "[.!?]\\s+") + let value = paragraph as NSString + var start = 0 + for match in regex.matches(in: paragraph, range: NSRange(location: 0, length: value.length)) { + try push(value.substring(with: NSRange(location: start, length: match.range.location + 1 - start))) + start = NSMaxRange(match.range) + } + try push(value.substring(from: start)) + } + guard chunks.count <= SpeechLimits.maximumChunks else { throw SpeechIssue.capacity } + } + flush() + guard chunks.count <= SpeechLimits.maximumChunks else { throw SpeechIssue.capacity } + return chunks + } + + public static func sanitize(_ text: String) throws -> String { + guard text.utf8.count <= SpeechLimits.maximumSourceBytes else { throw SpeechIssue.capacity } + var lines: [String] = [], fence: Character?, fenceLength = 0 + for line in text.components(separatedBy: .newlines) { + try Task.checkCancellation() + let opening = replace(line, #"^\s*(?:>+\s*)?"#, "") + let first = opening.first + let repeated = opening.prefix(while: { $0 == first }).count + if let active = fence { + if first == active, repeated >= fenceLength, + opening.dropFirst(repeated).trimmingCharacters(in: .whitespaces).isEmpty + { + fence = nil + } + } else if (first == "`" || first == "~"), repeated >= 3 { + fence = first; fenceLength = repeated + } else { + lines.append(line) + } + } + var value = replace(lines.joined(separator: "\n"), #"(?is).*?(?:|$)"#, "") + .precomposedStringWithCanonicalMapping + let marker = #"^[ \t]*(?:[-+*•‣⁃∙·◦▪▫●○■□]|\d{1,4}[.)]|\(\d{1,4}\))[ \t]+"# + value = value.components(separatedBy: "\n").map { line in + let compact = line.filter { !$0.isWhitespace } + if compact.count >= 3 && ["-", "*", "_"].contains(String(compact.prefix(1))) && Set(compact).count == 1 { + return "" + } + let isList = line.range(of: marker, options: .regularExpression) != nil + var result = replace(line, marker, "") + result = replaceDashSeparators(result) + if isList && !result.isEmpty + && replace(result, #"[*_~`]+$"#, "").range(of: #"[,.!?;:…]$"#, options: .regularExpression) == nil + { + result += "." + } + return result + }.joined(separator: "\n") + for (pattern, replacement) in [ + (#"\*\*([^*]+)\*\*"#, "$1"), (#"__([^_]+)__"#, "$1"), (#"\*([^*]+)\*"#, "$1"), + (#"_([^_\s][^_]*)_"#, "$1"), (#"~~([^~]+)~~"#, "$1"), (#"`([^`]+)`"#, "$1"), + (#"(?m)^\s*#{1,6}\s*"#, ""), (#"]*)?>"#, " "), + ] { value = replace(value, pattern, replacement) } + value = String( + String.UnicodeScalarView( + value.unicodeScalars.filter { scalar in + let number = scalar.value + return !(0x1F300...0x1FAFF).contains(number) && !(0x2600...0x27BF).contains(number) + && !(0x1F1E6...0x1F1FF).contains(number) && !(0xE0020...0xE007F).contains(number) + && ![0x200D, 0x20E3, 0xFE0E, 0xFE0F].contains(number) + })) + for (from, to) in [ + ("¯", " "), ("_", " "), ("“", "\""), ("”", "\""), ("‘", "'"), ("’", "'"), ("´", "'"), ("`", "'"), + ("[", " "), ("]", " "), ("|", " "), ("/", " "), ("#", " "), ("→", " "), ("←", " "), + ("♥", ""), ("☆", ""), ("♡", ""), ("©", ""), ("\\", ""), ("@", " at "), ("e.g.,", "for example, "), + ("i.e.,", "that is, "), + ] { value = value.replacingOccurrences(of: from, with: to) } + value = replace(value, #"\s+([,.!?;:'])"#, "$1") + value = replace(value, #""{2,}"#, "\"") + value = replace(value, #"'{2,}"#, "'") + value = value.components(separatedBy: "\n").map { + replace($0, #"[^\S\r\n]+"#, " ").trimmingCharacters(in: .whitespaces) + }.joined(separator: "\n") + return replace(value, #"\n{3,}"#, "\n\n").trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func normalize(_ value: String) -> String { + let text = replace(value, #"\s+"#, " ").trimmingCharacters(in: .whitespacesAndNewlines) + return text.range(of: #"[.!?;:,'"“”‘’)\]}…。」』】〉》›»]$"#, options: .regularExpression) != nil ? text : text + "." + } + private static func replaceDashSeparators(_ line: String) -> String { + guard let expression = try? NSRegularExpression(pattern: #"[ \t]+[-‑‒][ \t]+|[ \t]*[–—―][ \t]*"#) else { + return line + } + let source = line as NSString + let matches = expression.matches(in: line, range: NSRange(location: 0, length: source.length)) + guard !matches.isEmpty else { return line } + func neighbor(from offset: Int, forward: Bool) -> Character? { + var offset = offset + while offset >= 0 && offset < source.length { + let range = source.rangeOfComposedCharacterSequence(at: offset) + guard let character = source.substring(with: range).first else { return nil } + if !character.isWhitespace { return character } + offset = forward ? NSMaxRange(range) : range.location - 1 + } + return nil + } + var result = "", start = 0 + for match in matches { + let before = neighbor(from: match.range.location - 1, forward: false) + let after = neighbor(from: NSMaxRange(match.range), forward: true) + let replacement: String + if source.substring(with: match.range) == "–", let before, let after, + before.isLetter || before.isNumber, after.isLetter || after.isNumber + { + replacement = " to " + } else if before == nil || after == nil + || before.map({ ",.!?;:".contains($0) }) == true + || after.map({ ",.!?;:".contains($0) }) == true + { + replacement = " " + } else { + replacement = ", " + } + result += source.substring(with: NSRange(location: start, length: match.range.location - start)) + result += replacement; start = NSMaxRange(match.range) + } + return result + source.substring(from: start) + } + private static func replace(_ text: String, _ pattern: String, _ replacement: String) -> String { + text.replacingOccurrences(of: pattern, with: replacement, options: .regularExpression) + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Audio/TemporarySpeechStore.swift b/apps/maple-ios/Sources/MapleAppCore/Audio/TemporarySpeechStore.swift new file mode 100644 index 000000000..ea958494f --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Audio/TemporarySpeechStore.swift @@ -0,0 +1,201 @@ +import Darwin +import Foundation + +/// Temporary generated speech only. Complete-until-first-authentication is the +/// initial candidate because playback may reopen later chunks while locked. +/// Actual lock/reopen/protection behavior requires the final physical-device gate. +public actor TemporarySpeechStore: SpeechFileStoring { + private struct Entry { let owner: SpeechOwner; let name: String; var bytes: Int } + private let root: URL + private let maximumFileBytes: Int + private let maximumSessionBytes: Int + private var initialized = false + private var active: SpeechOwner? + private var retired = Set() + private var entries: [UUID: Entry] = [:] + private var allocated = 0 + private var failedCleanup = Set() + + public init( + root: URL, maximumFileBytes: Int = SpeechLimits.maximumFileBytes, + maximumSessionBytes: Int = SpeechLimits.maximumSessionBytes + ) { + self.root = root; self.maximumFileBytes = max(0, maximumFileBytes) + self.maximumSessionBytes = max(0, maximumSessionBytes) + } + + public func startupCleanup() throws { + guard active == nil, entries.isEmpty else { throw SpeechIssue.storage } + let directory = try openDirectory(); defer { close(directory) } + for name in try names(directory) where Self.ownedName(name) { try unlinkOwned(name, directory: directory) } + initialized = true + } + + public func activate(_ owner: SpeechOwner) throws { + guard initialized, failedCleanup.isEmpty else { throw SpeechIssue.storage } + guard !retired.contains(owner), active == nil || active == owner else { throw SpeechIssue.retired } + active = owner + } + + public func write(_ body: AsyncThrowingStream, owner: SpeechOwner) async throws -> StoredSpeechFile { + try require(owner) + let directory = try openDirectory(); defer { close(directory) } + let id = UUID(), + name = "speech-" + owner.sessionID.uuidString.lowercased() + "-" + id.uuidString.lowercased() + ".wav" + let descriptor = openat( + directory, name, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, S_IRUSR | S_IWUSR) + guard descriptor >= 0 else { throw SpeechIssue.storage } + defer { close(descriptor) } + entries[id] = Entry(owner: owner, name: name, bytes: 0) + do { + try validate(descriptor, name: name, directory: directory, expectedBytes: 0) + try protect(root.appendingPathComponent(name)) + for try await bytes in body { + try Task.checkCancellation(); try require(owner) + guard var entry = entries[id], entry.owner == owner else { throw SpeechIssue.retired } + guard bytes.count <= SpeechLimits.maximumStreamBlockBytes, + bytes.count <= maximumFileBytes - entry.bytes, + bytes.count <= maximumSessionBytes - allocated + else { throw SpeechIssue.capacity } + try validate(descriptor, name: name, directory: directory, expectedBytes: entry.bytes) + // Reservation and descriptor writes are synchronous on this actor. + entry.bytes += bytes.count; allocated += bytes.count; entries[id] = entry + try bytes.withUnsafeBytes { buffer in + guard let pointer = buffer.baseAddress else { return } + var offset = 0 + while offset < buffer.count { + let written = Darwin.write(descriptor, pointer.advanced(by: offset), buffer.count - offset) + if written < 0 && errno == EINTR { continue } + guard written > 0 else { throw SpeechIssue.storage } + offset += written + } + } + } + try Task.checkCancellation(); try require(owner) + guard let entry = entries[id], entry.bytes > 0 else { throw SpeechIssue.invalidAudio } + guard fsync(descriptor) == 0 else { throw SpeechIssue.storage } + try validate(descriptor, name: name, directory: directory, expectedBytes: entry.bytes) + try validateRoot(directory) + return StoredSpeechFile(id: id, url: root.appendingPathComponent(name), byteCount: entry.bytes) + } catch { + do { try removeEntry(id, directory: directory) } catch { + failedCleanup.insert(owner); throw SpeechIssue.storage + } + throw error + } + } + + public func remove(_ files: [StoredSpeechFile], owner: SpeechOwner) throws { + guard !files.isEmpty else { return } + let directory = try openDirectory(); defer { close(directory) } + for file in files { + guard let entry = entries[file.id] else { continue } + guard entry.owner == owner else { throw SpeechIssue.retired } + do { try removeEntry(file.id, directory: directory) } catch { + failedCleanup.insert(owner); throw SpeechIssue.storage + } + } + } + + public func retire(_ owner: SpeechOwner) throws { + retired.insert(owner) + if active == owner { active = nil } + failedCleanup.insert(owner) + let directory = try openDirectory(); defer { close(directory) } + do { + for id in entries.keys.filter({ entries[$0]?.owner == owner }) { try removeEntry(id, directory: directory) } + failedCleanup.remove(owner) + } catch { failedCleanup.insert(owner); throw SpeechIssue.storage } + } + + public var allocatedBytes: Int { allocated } + + private func require(_ owner: SpeechOwner) throws { + guard initialized, active == owner, !retired.contains(owner), failedCleanup.isEmpty else { + throw SpeechIssue.retired + } + } + private func removeEntry(_ id: UUID, directory: Int32) throws { + guard let entry = entries[id] else { return } + try unlinkOwned(entry.name, directory: directory) + entries.removeValue(forKey: id); allocated -= entry.bytes + } + private static func ownedName(_ name: String) -> Bool { + guard name.hasPrefix("speech-"), name.hasSuffix(".wav"), name.utf8.count == 84 else { return false } + let body = name.dropFirst(7).dropLast(4) + return body.dropFirst(36).first == "-" && UUID(uuidString: String(body.prefix(36))) != nil + && UUID(uuidString: String(body.dropFirst(37))) != nil + } + private func openDirectory() throws -> Int32 { + var info = stat() + if lstat(root.path, &info) != 0 { + guard errno == ENOENT else { throw SpeechIssue.storage } + do { + try FileManager.default.createDirectory( + at: root, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + } catch { throw SpeechIssue.storage } + } + let directory = open(root.path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + guard directory >= 0 else { throw SpeechIssue.storage } + do { + guard fstat(directory, &info) == 0, info.st_mode & S_IFMT == S_IFDIR, + info.st_uid == geteuid(), fchmod(directory, 0o700) == 0 + else { throw SpeechIssue.storage } + try validateRoot(directory); try protect(root) + return directory + } catch { close(directory); throw SpeechIssue.storage } + } + private func validateRoot(_ descriptor: Int32) throws { + var opened = stat(), current = stat() + guard fstat(descriptor, &opened) == 0, lstat(root.path, ¤t) == 0, + current.st_mode & S_IFMT == S_IFDIR, current.st_ino == opened.st_ino, current.st_dev == opened.st_dev + else { throw SpeechIssue.storage } + } + private func validate(_ descriptor: Int32, name: String, directory: Int32, expectedBytes: Int) throws { + var opened = stat(), current = stat() + guard fstat(descriptor, &opened) == 0, fstatat(directory, name, ¤t, AT_SYMLINK_NOFOLLOW) == 0, + opened.st_mode & S_IFMT == S_IFREG, opened.st_nlink == 1, opened.st_uid == geteuid(), + opened.st_size == expectedBytes, opened.st_ino == current.st_ino, opened.st_dev == current.st_dev + else { throw SpeechIssue.storage } + } + private func unlinkOwned(_ name: String, directory: Int32) throws { + guard Self.ownedName(name) else { throw SpeechIssue.storage } + var info = stat() + if fstatat(directory, name, &info, AT_SYMLINK_NOFOLLOW) != 0 { + guard errno == ENOENT else { throw SpeechIssue.storage }; return + } + // Unlink only this exact generated entry; never follow links or recurse. + guard info.st_uid == geteuid(), info.st_mode & S_IFMT != S_IFDIR, + unlinkat(directory, name, 0) == 0 + else { throw SpeechIssue.storage } + } + private func protect(_ url: URL) throws { + var target = url, values = URLResourceValues(); values.isExcludedFromBackup = true + do { + try target.setResourceValues(values) + #if os(iOS) + try FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], ofItemAtPath: url.path) + #endif + } catch { throw SpeechIssue.storage } + } + private func names(_ directory: Int32) throws -> [String] { + let duplicate = dup(directory) + guard duplicate >= 0 else { throw SpeechIssue.storage } + guard let stream = fdopendir(duplicate) else { close(duplicate); throw SpeechIssue.storage } + defer { closedir(stream) }; rewinddir(stream) + var result: [String] = [] + while true { + errno = 0 + guard let entry = readdir(stream) else { + guard errno == 0 else { throw SpeechIssue.storage }; return result + } + let name = withUnsafePointer(to: &entry.pointee.d_name) { + $0.withMemoryRebound(to: CChar.self, capacity: Int(NAME_MAX) + 1) { String(cString: $0) } + } + if name == "." || name == ".." { continue } + guard result.count < 1_024 else { throw SpeechIssue.capacity } + result.append(name) + } + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Auth/AccountLinkInbox.swift b/apps/maple-ios/Sources/MapleAppCore/Auth/AccountLinkInbox.swift new file mode 100644 index 000000000..bfe5a9229 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Auth/AccountLinkInbox.swift @@ -0,0 +1,47 @@ +import Foundation + +/// Holds at most one parsed account route while cold restoration is unresolved. +/// OAuth grants and URL-supplied reset secrets never enter this memory-only inbox. +@MainActor public final class AccountLinkInbox { + private struct Pending { + let link: IncomingAccountLink + let scope: AccountScope? + let deadline: TimeInterval + } + private var pending: Pending? + private let monotonicNow: @Sendable () -> TimeInterval + + public init(monotonicNow: @escaping @Sendable () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }) { + self.monotonicNow = monotonicNow + } + + /// A newer valid route supersedes the prior one. Callers parse exact origins + /// before offering, so invalid input cannot erase a waiting valid route. + public func offer(_ link: IncomingAccountLink, phase: SessionPhase, scope: AccountScope?) -> IncomingAccountLink? { + if (phase == .signedIn && scope != nil) || (phase == .signedOut && scope == nil) { + pending = nil; return link + } + switch phase { + case .restoring: + pending = Pending(link: link, scope: scope, deadline: monotonicNow() + 300) + case .authenticating, .cleaningUp: + retire() + default: break + } + return nil + } + + /// A cold, unbound route can use the first resolved account state. A route + /// captured under a known account cannot follow that account's replacement. + public func take(phase: SessionPhase, scope: AccountScope?) -> IncomingAccountLink? { + if phase == .authenticating || phase == .cleaningUp { retire(); return nil } + guard let pending else { return nil } + guard pending.deadline > monotonicNow() else { self.pending = nil; return nil } + guard (phase == .signedIn && scope != nil) || (phase == .signedOut && scope == nil) else { return nil } + self.pending = nil + guard pending.scope == nil || pending.scope == scope else { return nil } + return pending.link + } + + public func retire() { pending = nil } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Auth/AttestationClockIssue.swift b/apps/maple-ios/Sources/MapleAppCore/Auth/AttestationClockIssue.swift new file mode 100644 index 000000000..2ebf5d423 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Auth/AttestationClockIssue.swift @@ -0,0 +1,24 @@ +import Foundation +import MapleSDK + +/// Safe presentation categories from a typed SDK clock diagnosis. +/// No dates, signed documents, underlying descriptions or server content escape. +public enum AttestationClockIssue: Sendable, Equatable { + case deviceTime, certificateTiming + + static func classify(_ error: any Error) -> Self? { + var current: any Error = error + // Foundation may wrap SDK errors. Follow only its typed underlying-error + // contract, with a strict bound even if an NSError chain is cyclic. + for _ in 0..<8 { + if let clock = current as? AttestationClockSkewError { + return clock.deviceClockLikelyWrong ? .deviceTime : .certificateTiming + } + guard let underlying = (current as NSError).userInfo[NSUnderlyingErrorKey] as? any Error else { + return nil + } + current = underlying + } + return nil + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Auth/AuthCeremonies.swift b/apps/maple-ios/Sources/MapleAppCore/Auth/AuthCeremonies.swift new file mode 100644 index 000000000..2d1b1e703 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Auth/AuthCeremonies.swift @@ -0,0 +1,675 @@ +import Foundation +import MapleSDK +import Observation + +public struct BrowserHandoffReservation: Sendable, Equatable { + public let id: UUID + public let sessionID: String + public let requestID: String + public let expiresAt: Date + public init(id: UUID, sessionID: String, requestID: String, expiresAt: Date) { + self.id = id; self.sessionID = sessionID; self.requestID = requestID; self.expiresAt = expiresAt + } +} + +public struct BrowserAccountConfirmation: Sendable, Equatable { + public let id: UUID + public let principalID: String + public let email: String? + public init(id: UUID, principalID: String, email: String?) { + self.id = id; self.principalID = principalID; self.email = email + } +} + +public struct BrowserSignInRequest: Sendable, Equatable { + public let id: UUID + public let url: URL + public let callbackScheme: String + public let expiresAt: Date +} + +public struct AppleSignInRequest: Sendable, Equatable { + public let id: UUID + public let nonce: String + public let state: String + public let expiresAt: Date +} + +/// Tokens and the raw nonce never become observable view state or persisted data. +public struct AppleSignInCredential: Sendable { + public let identityToken: String + public let userIdentifier: String + public let state: String? + public let email: String? + public let givenName: String? + public let familyName: String? + public init( + identityToken: String, userIdentifier: String, state: String?, email: String? = nil, + givenName: String? = nil, familyName: String? = nil + ) { + self.identityToken = identityToken; self.userIdentifier = userIdentifier; self.state = state + self.email = email; self.givenName = givenName; self.familyName = familyName + } +} + +public protocol AuthCeremonyServing: Sendable { + func snapshot() async throws -> CredentialSnapshot + func requestPasswordReset(email: String, hashedSecret: String, expected: CredentialSnapshot) async throws + func confirmPasswordReset( + email: String, code: String, plaintextSecret: String, newPassword: String, + expected: CredentialSnapshot) async throws + func verifyEmail(code: String, expected: CredentialSnapshot) async throws + func resendVerification(expected: CredentialSnapshot) async throws + func changePassword(currentPassword: String, newPassword: String, expected: CredentialSnapshot) async throws + func requestAccountDeletion(hashedSecret: String, expected: CredentialSnapshot) async throws + func confirmAccountDeletion(code: String, plaintextSecret: String, expected: CredentialSnapshot) async throws + func signInWithApple( + _ credential: AppleSignInCredential, rawNonce: String, + expected: CredentialSnapshot) async throws + func prepareBrowser(id: UUID, expected: CredentialSnapshot) async throws -> BrowserHandoffReservation + func redeemBrowser( + _ reservation: BrowserHandoffReservation, grant: NativeOAuthHandoffGrant, + expected: CredentialSnapshot + ) async throws -> BrowserAccountConfirmation + func confirmBrowser(_ confirmation: BrowserAccountConfirmation, expected: CredentialSnapshot) async throws + func cancelBrowser(id: UUID) async +} + +public enum AuthCeremonyOutcome: Sendable, Equatable { + case verificationSent, emailVerified, passwordReset, passwordChanged, accountDeleted, signedIn +} +public enum AuthCeremonyState: Sendable, Equatable { + case idle, busy, resetCode(email: String), deletionCode + case apple(AppleSignInRequest), browser(BrowserSignInRequest), confirmBrowser(BrowserAccountConfirmation) + case completed(AuthCeremonyOutcome) +} +public enum AuthCeremonyIssue: Error, Sendable, Equatable { + case invalidInput, expired, changedAccount, invalidCallback, connection, invalidCredentials + case billingCleanupUnavailable, cleanupRequired, deletionUnconfirmed, browserSignInUnavailable + case clock(AttestationClockIssue) +} + +/// One screen/flow owner. Password reset and deletion secrets remain in memory, +/// bound to the original email/account and a bounded lifetime. Invalidation +/// cancels the actual task, so SDK authority commits observe cancellation too. +@MainActor @Observable public final class AuthCeremonies { + public private(set) var state: AuthCeremonyState = .idle + public private(set) var issue: AuthCeremonyIssue? + public let scope: AccountScope? + public let accountEmail: String? + @ObservationIgnored private let service: any AuthCeremonyServing + @ObservationIgnored private let configuration: AppConfiguration + @ObservationIgnored private let now: @Sendable () -> Date + @ObservationIgnored private let makeSecret: @Sendable () throws -> String + @ObservationIgnored private var operation = UUID() + @ObservationIgnored private var task: Task? + @ObservationIgnored private var expirationTask: Task? + @ObservationIgnored private var retired = false + @ObservationIgnored private var challenge: Challenge? + @ObservationIgnored private var apple: AppleAttempt? + @ObservationIgnored private var browser: BrowserAttempt? + /// Must quiesce account work and retain an admission fence before deletion. + @ObservationIgnored public var beforeAccountDeletion: (@MainActor (AccountScope) async throws -> Void)? + /// Release the fence on failure; retire account data only after confirmed success. + @ObservationIgnored public var onAccountDeletionResult: (@MainActor (AccountScope, Bool) async -> Void)? + + /// Confirmed password reset revokes the captured local account even after screen cancellation. + @ObservationIgnored public var onPasswordResetResult: (@MainActor (AccountScope) async -> Void)? + + private struct Challenge { + let secret: String + let email: String? + let started: Date + let expected: CredentialSnapshot + } + private struct AppleAttempt { + let request: AppleSignInRequest + let rawNonce: String + let started: Date + let expected: CredentialSnapshot + } + private struct BrowserAttempt { + let reservation: BrowserHandoffReservation + let started: Date + let expected: CredentialSnapshot + } + + public init( + service: any AuthCeremonyServing, configuration: AppConfiguration, scope: AccountScope? = nil, + accountEmail: String? = nil, + now: @escaping @Sendable () -> Date = { Date() }, + makeSecret: @escaping @Sendable () throws -> String = generateSecureSecret + ) { + self.service = service; self.configuration = configuration; self.scope = scope + self.accountEmail = accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines) + self.now = now; self.makeSecret = makeSecret + } + + public var isBusy: Bool { state == .busy } + private var mayStart: Bool { + if case .completed = state { return !retired } + return !retired && state == .idle + } + + public func requestPasswordReset(email: String) async { + let email = email.trimmingCharacters(in: .whitespacesAndNewlines) + guard mayStart else { return } + guard !email.isEmpty, email.utf8.count <= 320 else { issue = .invalidInput; return } + if scope != nil { + guard let accountEmail, !accountEmail.isEmpty, email.caseInsensitiveCompare(accountEmail) == .orderedSame + else { + issue = .invalidInput; return + } + } + await perform(recovering: .idle) { [self] in + let expected = try await capture() + let secret = try makeSecret(), started = now() + try await service.requestPasswordReset(email: email, hashedSecret: hashSecret(secret), expected: expected) + try await check(expected) + challenge = Challenge(secret: secret, email: email, started: started, expected: expected) + state = .resetCode(email: email) + scheduleExpiration(started.addingTimeInterval(900)) + } + } + + public func confirmPasswordReset(code: String, newPassword: String, confirmation: String) async { + guard case .resetCode(let email) = state, let challenge, !retired else { return } + guard validCode(code), Self.validPassword(newPassword), newPassword == confirmation else { + issue = .invalidInput; return + } + await perform(recovering: .resetCode(email: email)) { [self] in + let ownedOperation = operation + let completedScope = scope + let onPasswordResetResult = self.onPasswordResetResult + try validAge(challenge.started) + try await check(challenge.expected) + try await service.confirmPasswordReset( + email: email, code: code, plaintextSecret: challenge.secret, + newPassword: newPassword, expected: challenge.expected) + // A successful response is destructive server confirmation. Preserve + // cleanup for its original scope even if this screen was dismissed. + if operation == ownedOperation, !retired { + do { + try await check(challenge.expected) + if operation == ownedOperation, !retired { + self.challenge = nil; state = .completed(.passwordReset) + } + } catch { + if operation == ownedOperation, !retired { + self.challenge = nil; state = .idle; issue = .changedAccount + } + } + } + if let completedScope { await onPasswordResetResult?(completedScope) } + } + } + + public func verifyEmail(code: String) async { + guard mayStart else { return } + guard !code.isEmpty, code.utf8.count <= 256, + code.utf8.allSatisfy({ + (48...57).contains($0) || (65...90).contains($0) || (97...122).contains($0) || $0 == 45 + }) + else { issue = .invalidInput; return } + await perform(recovering: .idle) { [self] in + let expected = try await capture() + try await service.verifyEmail(code: code, expected: expected) + try await check(expected) + state = .completed(.emailVerified) + } + } + + public func resendVerification() async { + guard mayStart, scope != nil else { return } + await perform(recovering: .idle) { [self] in + let expected = try await capture() + try await service.resendVerification(expected: expected) + try await check(expected) + state = .completed(.verificationSent) + } + } + + public func changePassword(currentPassword: String, newPassword: String, confirmation: String) async { + guard mayStart, scope != nil else { return } + guard !currentPassword.isEmpty, Self.validPassword(newPassword), newPassword == confirmation else { + issue = .invalidInput; return + } + await perform(recovering: .idle) { [self] in + let expected = try await capture() + try await service.changePassword( + currentPassword: currentPassword, newPassword: newPassword, expected: expected) + try await check(expected) + state = .completed(.passwordChanged) + } + } + + public func requestAccountDeletion(confirmation: String) async { + guard mayStart, scope != nil else { return } + guard confirmation == "DELETE" else { issue = .invalidInput; return } + await perform(recovering: .idle) { [self] in + let expected = try await capture() + let secret = try makeSecret(), started = now() + try await service.requestAccountDeletion(hashedSecret: hashSecret(secret), expected: expected) + try await check(expected) + challenge = Challenge(secret: secret, email: nil, started: started, expected: expected) + state = .deletionCode + scheduleExpiration(started.addingTimeInterval(900)) + } + } + + public func confirmAccountDeletion(code: String) async { + guard state == .deletionCode, let challenge, let scope, !retired else { return } + guard UUID(uuidString: code) != nil else { issue = .invalidInput; return } + await perform(recovering: .deletionCode) { [self] in + let ownedOperation = operation + let onAccountDeletionResult = self.onAccountDeletionResult + try validAge(challenge.started) + try await check(challenge.expected) + guard let beforeAccountDeletion else { throw AuthCeremonyIssue.cleanupRequired } + do { + try await beforeAccountDeletion(scope) + try await check(challenge.expected) + try await service.confirmAccountDeletion( + code: code, plaintextSecret: challenge.secret, + expected: challenge.expected) + if operation == ownedOperation, !retired { + self.challenge = nil; state = .completed(.accountDeleted) + } + await onAccountDeletionResult?(scope, true) + } catch { + await onAccountDeletionResult?(scope, false) + throw error + } + } + } + + public func beginAppleSignIn() async -> AppleSignInRequest? { + guard mayStart, scope == nil else { return nil } + let owned = await perform(recovering: .idle) { [self] in + let expected = try await capture() + let rawNonce = try makeSecret(), started = now() + let request = AppleSignInRequest( + id: UUID(), nonce: hashSecret(rawNonce), state: UUID().uuidString.lowercased(), + expiresAt: started.addingTimeInterval(900)) + apple = AppleAttempt(request: request, rawNonce: rawNonce, started: started, expected: expected) + state = .apple(request) + scheduleExpiration(request.expiresAt) + } + guard owned else { return nil } + if case .apple(let request) = state { return request } + return nil + } + + public func completeAppleSignIn(id: UUID, credential: AppleSignInCredential) async { + guard case .apple(let request) = state, request.id == id, let apple, !retired else { return } + self.apple = nil // Every provider callback consumes this attempt before any await. + await perform(recovering: .idle) { [self] in + try validAge(apple.started) + guard credential.state == request.state, !credential.identityToken.isEmpty, + credential.identityToken.utf8.count <= 64 * 1024, !credential.userIdentifier.isEmpty, + credential.userIdentifier.utf8.count <= 1024 + else { throw AuthCeremonyIssue.invalidCallback } + try await check(apple.expected) + try await service.signInWithApple(credential, rawNonce: apple.rawNonce, expected: apple.expected) + try Task.checkCancellation() + state = .completed(.signedIn) + } + } + + public func beginBrowserSignIn(provider: OAuthProvider) async -> BrowserSignInRequest? { + guard mayStart, scope == nil, provider == .google || provider == .github else { return nil } + // Reject targets the browser broker cannot return to before reserving SDK state. + guard supportsBrowserCallback else { issue = .browserSignInUnavailable; return nil } + let owned = await perform(recovering: .idle) { [self] in + let expected = try await capture(), started = now(), id = UUID() + // Register the ID before preparation so cancellation also retires a delayed handshake. + let placeholder = BrowserHandoffReservation(id: id, sessionID: "", requestID: "", expiresAt: started) + browser = BrowserAttempt(reservation: placeholder, started: started, expected: expected) + let reservation = try await service.prepareBrowser(id: id, expected: expected) + try await check(expected) + let url = try browserURL(provider: provider, reservation: reservation) + guard reservation.id == id, reservation.expiresAt > now() else { throw AuthCeremonyIssue.expired } + browser = BrowserAttempt(reservation: reservation, started: started, expected: expected) + state = .browser( + BrowserSignInRequest( + id: id, url: url, callbackScheme: configuration.callbackScheme, + expiresAt: min(reservation.expiresAt, started.addingTimeInterval(900)))) + scheduleExpiration(min(reservation.expiresAt, started.addingTimeInterval(900))) + } + guard owned else { return nil } + if case .browser(let request) = state { return request } + await discardBrowser() + return nil + } + + public func redeemBrowserCallback(id: UUID, url: URL) async { + guard case .browser(let request) = state, request.id == id, let browser, !retired else { return } + let owned = await perform(recovering: .idle) { [self] in + try validBrowser(browser) + let grant = try NativeOAuthHandoffGrant(callbackURL: url, expectedScheme: configuration.callbackScheme) + try await check(browser.expected) + let confirmation = try await service.redeemBrowser( + browser.reservation, grant: grant, expected: browser.expected) + try await check(browser.expected) + try validBrowser(browser) + guard confirmation.id == id, !confirmation.principalID.isEmpty else { + throw AuthCeremonyIssue.invalidCallback + } + state = .confirmBrowser(confirmation) + } + if owned, state == .idle { await discardBrowser() } + } + + /// Call only from the explicit account-confirmation button, never automatically on redemption. + public func confirmBrowserAccount(id: UUID) async { + guard case .confirmBrowser(let confirmation) = state, confirmation.id == id, let browser, !retired else { + return + } + let owned = await perform(recovering: .idle) { [self] in + try validBrowser(browser) + try await check(browser.expected) + try await service.confirmBrowser(confirmation, expected: browser.expected) + try Task.checkCancellation() + self.browser = nil; state = .completed(.signedIn) + } + if owned, state == .idle { await discardBrowser() } + } + + public func cancel() async { + let id = browser?.reservation.id + reset(retiring: false) + if let id { await service.cancelBrowser(id: id) } + } + + public func invalidate() { + let id = browser?.reservation.id + reset(retiring: true) + if let id { Task { await service.cancelBrowser(id: id) } } + } + + private func reset(retiring: Bool) { + operation = UUID(); task?.cancel(); task = nil + expirationTask?.cancel(); expirationTask = nil + challenge = nil; apple = nil; browser = nil; retired = retiring + state = .idle; issue = nil + } + + private func discardBrowser() async { + let id = browser?.reservation.id; browser = nil + if let id { await service.cancelBrowser(id: id) } + } + + @discardableResult private func perform( + recovering: AuthCeremonyState, action: @escaping @MainActor () async throws -> Void + ) async -> Bool { + let ticket = UUID(); operation = ticket; state = .busy; issue = nil + let pending = Task { [weak self] in + guard let self else { return } + do { try await action() } catch { + guard operation == ticket, !retired else { return } + state = recovering; issue = Self.present(error) + if issue == .expired || issue == .changedAccount { + challenge = nil; apple = nil; state = .idle + } + } + if operation == ticket { + task = nil + if case .completed = state { expirationTask?.cancel(); expirationTask = nil } + expireIfNeeded() + } + } + task = pending + await pending.value + return operation == ticket && !retired + } + + /// Invoke on foregrounding as well as the timer, since suspension delays timers. + public func expireIfNeeded() { + guard !isBusy else { return } + let expired: Bool + if let challenge { + expired = (try? validAge(challenge.started)) == nil + } else if let apple { + expired = (try? validAge(apple.started)) == nil + } else if let browser { + expired = (try? validBrowser(browser)) == nil + } else { + return + } + guard expired else { return } + let id = browser?.reservation.id + reset(retiring: false); issue = .expired + if let id { Task { await service.cancelBrowser(id: id) } } + } + + private func scheduleExpiration(_ deadline: Date) { + expirationTask?.cancel() + let delay = max(0, deadline.timeIntervalSince(now())) + expirationTask = Task { [weak self] in + do { try await Task.sleep(for: .seconds(delay)) } catch { return } + self?.expireIfNeeded() + } + } + + private func capture() async throws -> CredentialSnapshot { + let snapshot = try await service.snapshot() + try Task.checkCancellation() + guard !retired, snapshot.apiOrigin == configuration.apiURL.absoluteString, + snapshot.principalId == scope?.principalID, + scope == nil || scope?.apiOrigin == snapshot.apiOrigin + else { throw AuthCeremonyIssue.changedAccount } + return snapshot + } + + private func check(_ expected: CredentialSnapshot) async throws { + let current = try await capture() + guard current.apiOrigin == expected.apiOrigin, current.principalId == expected.principalId, + current.generation == expected.generation + else { throw AuthCeremonyIssue.changedAccount } + } + + private func validAge(_ started: Date) throws { + let age = now().timeIntervalSince(started) + guard age >= 0, age < 900 else { throw AuthCeremonyIssue.expired } + } + private func validBrowser(_ attempt: BrowserAttempt) throws { + try validAge(attempt.started) + guard now() < attempt.reservation.expiresAt else { throw AuthCeremonyIssue.expired } + } + private func validCode(_ value: String) -> Bool { + value.utf8.count == 8 && value.utf8.allSatisfy { (48...57).contains($0) || (65...90).contains($0) } + } + public nonisolated static func validPassword(_ value: String) -> Bool { + value.utf16.count >= 8 && value.utf8.count <= 16 * 1024 + } + + private var supportsBrowserCallback: Bool { + configuration.callbackScheme == "cloud.opensecret.maple" + || configuration.callbackScheme == "cloud.opensecret.maple.dev" + } + + private func browserURL(provider: OAuthProvider, reservation: BrowserHandoffReservation) throws -> URL { + func validID(_ value: String) -> Bool { + value.utf8.count == 32 && value.utf8.allSatisfy { (48...57).contains($0) || (97...102).contains($0) } + } + guard validID(reservation.sessionID), validID(reservation.requestID), + supportsBrowserCallback, + var parts = URLComponents(url: configuration.authURL, resolvingAgainstBaseURL: false) + else { throw AuthCeremonyIssue.invalidCallback } + parts.path = "/desktop-auth" + parts.queryItems = [ + URLQueryItem(name: "provider", value: provider.rawValue), URLQueryItem(name: "transport", value: "v2"), + URLQueryItem(name: "native_session_id", value: reservation.sessionID), + URLQueryItem(name: "native_request_id", value: reservation.requestID), + ] + if configuration.callbackScheme == "cloud.opensecret.maple.dev" { + parts.queryItems?.append(URLQueryItem(name: "native_app_variant", value: "dev")) + } + guard let url = parts.url else { throw AuthCeremonyIssue.invalidCallback } + return url + } + + private static func present(_ error: Error) -> AuthCeremonyIssue { + if let clock = AttestationClockIssue.classify(error) { return .clock(clock) } + if let issue = error as? AuthCeremonyIssue { return issue } + if let error = error as? ResearchAPIError { + switch error { + case .invalidCallback: return .invalidCallback + case .expiredAuthenticationAttempt: return .expired + case .invalidArgument: return .invalidInput + case .http(_, .billingCleanupUnavailable): return .billingCleanupUnavailable + case .http(_, .invalidLogin): return .invalidCredentials + default: break + } + } + if let error = error as? TransportError, error == .authorityChanged || error == .signedOut { + return .changedAccount + } + return .connection + } +} + +/// SDK handles remain private to this actor. The app receives only public +/// routing identifiers and the verified account shown for confirmation. +public actor SDKAuthCeremonyService: AuthCeremonyServing { + private let client: MapleClient + private var activeBrowserID: UUID? + private var prepared: PreparedNativeOAuthHandoff? + private var confirmation: NativeSignInConfirmation? + + public init(client: MapleClient) { self.client = client } + public func snapshot() async throws -> CredentialSnapshot { try await client.credentials.snapshot() } + + public func requestPasswordReset(email: String, hashedSecret: String, expected: CredentialSnapshot) async throws { + try await check(expected) + try await client.anonymousAPI.requestPasswordReset( + email: email, hashedSecret: hashedSecret, clientId: client.clientID) + try await check(expected) + } + public func confirmPasswordReset( + email: String, code: String, plaintextSecret: String, newPassword: String, + expected: CredentialSnapshot + ) async throws { + try await check(expected) + try await client.anonymousAPI.confirmPasswordReset( + email: email, code: code, plaintextSecret: plaintextSecret, + newPassword: newPassword, clientId: client.clientID) + // Do not discard a confirmed destructive result after an account change. + // The caller cleans only its captured scope and fences UI publication. + } + public func verifyEmail(code: String, expected: CredentialSnapshot) async throws { + try await check(expected) + try await client.anonymousAPI.verifyEmail(code: code) + try await check(expected) + } + public func resendVerification(expected: CredentialSnapshot) async throws { + try await accountAPI(expected).requestNewVerificationEmail() + try await check(expected) + } + public func changePassword(currentPassword: String, newPassword: String, expected: CredentialSnapshot) async throws + { + try await client.changePassword(currentPassword: currentPassword, newPassword: newPassword, expected: expected) + } + public func requestAccountDeletion(hashedSecret: String, expected: CredentialSnapshot) async throws { + try await accountAPI(expected).requestAccountDeletion(hashedSecret: hashedSecret) + try await check(expected) + } + public func confirmAccountDeletion(code: String, plaintextSecret: String, expected: CredentialSnapshot) async throws + { + try await client.confirmAccountDeletion(code: code, plaintextSecret: plaintextSecret, expected: expected) + } + public func signInWithApple( + _ credential: AppleSignInCredential, rawNonce: String, + expected: CredentialSnapshot + ) async throws { + guard expected.principalId == nil else { throw TransportError.authorityChanged } + try await check(expected) + let login = try await client.anonymousAPI.handleAppleNativeSignIn( + identityToken: credential.identityToken, userIdentifier: credential.userIdentifier, rawNonce: rawNonce, + clientId: client.clientID, email: credential.email, givenName: credential.givenName, + familyName: credential.familyName) + try Task.checkCancellation() + _ = try await client.install(login, expected: expected) + } + + public func prepareBrowser(id: UUID, expected: CredentialSnapshot) async throws -> BrowserHandoffReservation { + guard activeBrowserID == nil, expected.principalId == nil else { throw TransportError.authorityChanged } + activeBrowserID = id + do { + try await check(expected) + let result = try await client.transport.prepareNativeOAuthHandoff() + try await check(expected) + guard activeBrowserID == id else { throw TransportError.authorityChanged } + prepared = result + return BrowserHandoffReservation( + id: id, sessionID: result.sessionID, requestID: result.requestID, + expiresAt: result.expiresAt) + } catch { + await cancelBrowser(id: id) + throw error + } + } + + public func redeemBrowser( + _ reservation: BrowserHandoffReservation, grant: NativeOAuthHandoffGrant, + expected: CredentialSnapshot + ) async throws -> BrowserAccountConfirmation { + guard activeBrowserID == reservation.id, let prepared, + prepared.sessionID == reservation.sessionID, prepared.requestID == reservation.requestID, + prepared.expiresAt == reservation.expiresAt + else { throw TransportError.authorityChanged } + self.prepared = nil + do { + try await check(expected) + let result = try await client.transport.redeemNativeOAuthHandoff(prepared, grant: grant) + try await check(expected) + guard activeBrowserID == reservation.id else { throw TransportError.authorityChanged } + confirmation = result + return BrowserAccountConfirmation(id: reservation.id, principalID: result.principalID, email: result.email) + } catch { + await cancelBrowser(id: reservation.id) + throw error + } + } + + public func confirmBrowser(_ account: BrowserAccountConfirmation, expected: CredentialSnapshot) async throws { + guard activeBrowserID == account.id, let confirmation, + confirmation.principalID == account.principalID, confirmation.email == account.email + else { + throw TransportError.authorityChanged + } + self.confirmation = nil + do { + try await check(expected) + _ = try await client.transport.confirmNativeOAuthHandoff(confirmation) + if activeBrowserID == account.id { activeBrowserID = nil } + } catch { + await cancelBrowser(id: account.id) + throw error + } + } + + public func cancelBrowser(id: UUID) async { + guard activeBrowserID == id else { return } + activeBrowserID = nil; prepared = nil; confirmation = nil + await client.transport.cancelNativeOAuthHandoff() + } + + private func accountAPI(_ expected: CredentialSnapshot) async throws -> ResearchAPI { + try await check(expected) + let api = try await client.accountAPI() + try await check(expected) + return api + } + private func check(_ expected: CredentialSnapshot) async throws { + try Task.checkCancellation() + let current = try await client.credentials.snapshot() + try Task.checkCancellation() + guard current.apiOrigin == expected.apiOrigin, current.principalId == expected.principalId, + current.generation == expected.generation, + expected.principalId != nil || current.revision == expected.revision + else { + throw TransportError.authorityChanged + } + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Auth/IncomingAccountLink.swift b/apps/maple-ios/Sources/MapleAppCore/Auth/IncomingAccountLink.swift new file mode 100644 index 000000000..611684e4a --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Auth/IncomingAccountLink.swift @@ -0,0 +1,37 @@ +import Foundation + +/// An allowlisted account route. No credential or private reset secret is +/// retained from a URL; reset confirmation requires the app-owned ceremony. +public enum IncomingAccountLink: Equatable, Sendable { + case verify(code: String) + case passwordReset(code: String?) + + public init?(url: URL, configuration: AppConfiguration) { + guard url.absoluteString.utf8.count <= 4_096, + let parts = URLComponents(url: url, resolvingAgainstBaseURL: false), + let origin = URLComponents(url: configuration.authURL, resolvingAgainstBaseURL: false), + parts.scheme == origin.scheme, parts.host == origin.host, parts.port == origin.port, + parts.percentEncodedHost == parts.host, + parts.user == nil, parts.password == nil, parts.fragment == nil, + !parts.percentEncodedPath.contains("%"), !parts.path.contains("\\") + else { return nil } + if parts.path.hasPrefix("/verify/"), parts.query == nil { + let code = String(parts.path.dropFirst("/verify/".count)) + guard UUID(uuidString: code) != nil else { return nil } + self = .verify(code: code) + return + } + guard parts.path == "/password-reset" || parts.path == "/password-reset/confirm" else { return nil } + let items = parts.queryItems ?? [] + guard Set(items.map(\.name)).count == items.count, + items.allSatisfy({ ["code", "email", "secret"].contains($0.name) && ($0.value?.utf8.count ?? 0) <= 1_024 }) + else { return nil } + let code = items.first(where: { $0.name == "code" })?.value + if let code { + guard code.utf8.count == 8, + code.utf8.allSatisfy({ (48...57).contains($0) || (65...90).contains($0) }) + else { return nil } + } + self = .passwordReset(code: code) + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Auth/SDKSessionService.swift b/apps/maple-ios/Sources/MapleAppCore/Auth/SDKSessionService.swift new file mode 100644 index 000000000..9ea051819 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Auth/SDKSessionService.swift @@ -0,0 +1,37 @@ +import Foundation +import MapleSDK + +public struct SDKSessionService: SessionServing { + public let client: MapleClient + public init(client: MapleClient) { self.client = client } + public func authorityChanges() async -> AsyncStream? { + await client.credentials.changes() + } + public func authorityState() async -> CredentialAuthorityChange? { await client.credentials.currentState() } + public func completePendingCleanup() async throws { try await client.credentials.completePendingCleanup() } + public func identity() async throws -> SessionIdentity? { + let snapshot = try await client.credentials.snapshot() + guard let principal = snapshot.principalId else { return nil } + return SessionIdentity(origin: snapshot.apiOrigin, principal: principal, generation: snapshot.generation) + } + public func user() async throws -> MapleUser { try await client.accountAPI().getUser() } + public func signIn(email: String, password: String) async throws { + _ = try await client.signIn(email: email, password: password) + } + public func signInGuest(id: String, password: String) async throws { + _ = try await client.signInGuest(id: id, password: password) + } + public func signUp(email: String, password: String, name: String?) async throws { + _ = try await client.signUp(email: email, password: password, inviteCode: "", name: name) + } + public func signOut() async throws { try await client.signOut() } + public func signOut(ifOwnedBy identity: SessionIdentity?) async throws { + guard let identity else { return } + let current = try await client.credentials.snapshot() + guard current.apiOrigin == identity.origin, current.principalId == identity.principal, + current.generation == identity.generation + else { return } + do { try await client.signOut(expected: current) } catch TransportError.authorityChanged + { /* The replacement owns its credentials. */ } + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Auth/SessionController.swift b/apps/maple-ios/Sources/MapleAppCore/Auth/SessionController.swift new file mode 100644 index 000000000..73c4da6a1 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Auth/SessionController.swift @@ -0,0 +1,328 @@ +import Foundation +import MapleSDK +import Observation + +public struct SessionIdentity: Sendable, Equatable { + public let origin: String + public let principal: String + public let generation: UUID + public init(origin: String, principal: String, generation: UUID) { + self.origin = origin; self.principal = principal; self.generation = generation + } +} + +public protocol SessionServing: Sendable { + func identity() async throws -> SessionIdentity? + func user() async throws -> MapleUser + func signIn(email: String, password: String) async throws + func signInGuest(id: String, password: String) async throws + func signUp(email: String, password: String, name: String?) async throws + func signOut() async throws + func signOut(ifOwnedBy identity: SessionIdentity?) async throws + func authorityChanges() async -> AsyncStream? + func authorityState() async -> CredentialAuthorityChange? + func completePendingCleanup() async throws +} + +extension SessionServing { + public func signOut(ifOwnedBy identity: SessionIdentity?) async throws { + guard let identity, try await self.identity() == identity else { return } + try await signOut() + } + public func authorityChanges() async -> AsyncStream? { nil } + public func authorityState() async -> CredentialAuthorityChange? { nil } + public func completePendingCleanup() async throws {} +} + +public protocol AccountDataLifecycle: Sendable { + func activate(_ scope: AccountScope) async throws + func retire(_ scope: AccountScope) async throws + func clearSignedOutData() async throws +} + +extension ProtectedChatStore: AccountDataLifecycle {} + +public struct AccountSession: Sendable, Equatable { + public let scope: AccountScope + public var user: MapleUser +} + +public enum SessionPhase: Sendable { case restoring, signedOut, authenticating, signedIn, unavailable, cleaningUp } +public enum SessionIssue: Sendable, Equatable { + case connection, credentials, emailAlreadyRegistered, invalidInput, storage, cleanup + case clock(AttestationClockIssue) +} + +/// The account lease is revoked synchronously before any asynchronous teardown. +/// A network failure preserves credentials and protected drafts for an explicit +/// retry; only confirmed absence of credential authority enters signed-out state. +@MainActor @Observable public final class SessionController { + public private(set) var phase: SessionPhase = .restoring + public private(set) var account: AccountSession? + public private(set) var issue: SessionIssue? + @ObservationIgnored private let service: any SessionServing + @ObservationIgnored private let storage: any AccountDataLifecycle + @ObservationIgnored private var operation = UUID() + @ObservationIgnored private var authorityEvent = UUID() + @ObservationIgnored private var cleanupScope: AccountScope? + @ObservationIgnored private var accountIdentity: SessionIdentity? + @ObservationIgnored private var resolvingIdentity: SessionIdentity? + @ObservationIgnored private var cleanupIntent: CleanupIntent? + @ObservationIgnored private var cleanupIdentity: SessionIdentity? + @ObservationIgnored private var revocationCompleted = false + @ObservationIgnored private var observation: Task? + private enum CleanupIntent { case signOut, replacement } + @ObservationIgnored public var onRetire: (@MainActor (AccountScope) async throws -> Void)? + @ObservationIgnored public var onCancelExternal: (@MainActor () async -> Void)? + + public init(service: any SessionServing, storage: any AccountDataLifecycle) { + self.service = service; self.storage = storage + } + + deinit { observation?.cancel() } + + /// One lifetime subscription. The task does not retain the controller while + /// awaiting events, and cancelling it unregisters the SDK observer. + public func startObservingAuthority() { + guard observation == nil else { return } + let service = service + observation = Task { [weak self] in + guard let changes = await service.authorityChanges() else { return } + for await change in changes { + guard !Task.isCancelled else { return } + await self?.authorityChanged(change) + } + } + } + + public func restore() async { + startObservingAuthority() + guard account == nil, phase != .authenticating, phase != .cleaningUp else { return } + if let intent = cleanupIntent { + await cleanUp(intent, retryCredentials: true) + return + } + let ticket = UUID(); operation = ticket; phase = .restoring; issue = nil + let state = await service.authorityState() + guard owns(ticket) else { return } + if state?.requiresCleanup == true { + await cleanUp(.replacement, retryCredentials: true) + return + } + await resolve(ticket) + } + + /// Delivery may be delayed or coalesced. Compare with the actual authority + /// before retiring any app generation; an old event cannot sign out a new one. + func authorityChanged(_ change: CredentialAuthorityChange) async { + authorityEvent = UUID() + guard phase != .cleaningUp, cleanupIntent == nil else { return } + let ticket = operation + guard await service.authorityState() == change, owns(ticket), phase != .cleaningUp else { return } + let identity = accountIdentity ?? resolvingIdentity + if let identity, identity.origin == change.apiOrigin, + identity.generation != change.generation || change.requiresCleanup + { + await cleanUp(.replacement, retryCredentials: false) + } else if identity == nil, change.requiresCleanup { + await cleanUp(.replacement, retryCredentials: false) + } else if phase == .signedOut { + // For example, another client sharing the authority completed auth. + do { + guard try await service.identity() != nil, owns(ticket), phase == .signedOut else { return } + operation = UUID(); phase = .restoring + await resolve(operation) + } catch { /* A durable failure is handled by its authority event. */ } + } + } + + public func signIn(email: String, password: String) async { + await authenticate { try await self.service.signIn(email: email, password: password) } + } + + public func signInGuest(id: String, password: String) async { + await authenticate { try await self.service.signInGuest(id: id, password: password) } + } + + public func signUp(email: String, password: String) async { + await authenticate { + guard AuthCeremonies.validPassword(password) else { throw ResearchAPIError.invalidArgument } + try await self.service.signUp(email: email, password: password, name: "ANON") + } + } + + public func beginExternalAuthentication() -> UUID? { + guard phase == .signedOut, cleanupScope == nil, cleanupIntent == nil else { return nil } + let ticket = UUID(); operation = ticket; phase = .authenticating; issue = nil + return ticket + } + + public func finishExternalAuthentication(_ ticket: UUID) async { + guard owns(ticket), phase == .authenticating else { return } + await resolve(ticket) + } + + /// The ceremony cancels its provider task and SDK reservation before this + /// check. If installation already committed, resolve its owned authority; + /// never present sign-in while hidden credentials remain installed. + public func cancelExternalAuthentication(_ ticket: UUID) async { + guard owns(ticket), phase == .authenticating else { return } + await onCancelExternal?() + guard owns(ticket) else { return } + await resolve(ticket) + } + + private func authenticate(_ action: () async throws -> Void) async { + guard phase == .signedOut, cleanupScope == nil, cleanupIntent == nil else { return } + let ticket = UUID(); operation = ticket; phase = .authenticating; issue = nil + do { + try await action() + guard owns(ticket) else { return } + await resolve(ticket) + } catch { + guard owns(ticket) else { return } + // A request may have installed authority before a later step failed. + // Never enable a second sign-in without checking that authority. + do { + let current = try await service.identity() + guard owns(ticket) else { return } + phase = current == nil ? .signedOut : .unavailable + issue = Self.present(error) + } catch { + guard owns(ticket) else { return } + phase = .unavailable; issue = .storage + } + } + } + + private func resolve(_ ticket: UUID) async { + do { + guard let identity = try await service.identity() else { + guard owns(ticket) else { return } + try await storage.clearSignedOutData() + guard owns(ticket) else { return } + // An install may have happened while protected cleanup awaited. + // Its stream event can already have been consumed during restore. + let observed = authorityEvent + let current = try await service.identity() + guard owns(ticket) else { return } + if current != nil || authorityEvent != observed { await resolve(ticket); return } + resolvingIdentity = nil; phase = .signedOut; issue = nil + return + } + guard owns(ticket) else { return } + resolvingIdentity = identity + let user = try await service.user() + guard owns(ticket) else { return } + let confirmed = try await service.identity() + guard owns(ticket) else { return } + guard confirmed == identity else { + await cleanUp(.replacement, retryCredentials: false); return + } + guard user.id == identity.principal else { + resolvingIdentity = nil; phase = .unavailable; issue = .credentials; return + } + let scope = AccountScope(apiOrigin: identity.origin, principalID: identity.principal) + cleanupScope = scope + try await storage.activate(scope) + guard owns(ticket) else { + try? await storage.retire(scope) + return + } + guard try await service.identity() == identity, owns(ticket) else { + if owns(ticket) { await cleanUp(.replacement, retryCredentials: false) } + return + } + cleanupScope = nil; resolvingIdentity = nil + account = AccountSession(scope: scope, user: user) + accountIdentity = identity + phase = .signedIn; issue = nil + } catch { + guard owns(ticket) else { return } + if cleanupScope != nil { cleanupIntent = .replacement } + phase = .unavailable + issue = error is ChatStorageError ? .storage : Self.present(error) + } + } + + public func signOut() async { await cleanUp(.signOut, retryCredentials: true) } + + private func cleanUp(_ intent: CleanupIntent, retryCredentials: Bool) async { + guard phase != .cleaningUp else { return } + let ticket = UUID(); operation = ticket + let scope = account?.scope ?? cleanupScope + let priorIntent = cleanupIntent + if intent == .signOut, priorIntent != .signOut { + cleanupIdentity = accountIdentity ?? resolvingIdentity + revocationCompleted = false + } + account = nil; accountIdentity = nil; resolvingIdentity = nil + cleanupScope = scope; cleanupIntent = intent; phase = .cleaningUp; issue = nil + var failed = false + if intent == .signOut, priorIntent != .signOut, cleanupIdentity == nil { + do { cleanupIdentity = try await service.identity() } catch { failed = true } + } + guard owns(ticket) else { return } + await onCancelExternal?() + if let scope { + do { try await onRetire?(scope) } catch { failed = true } + } + guard owns(ticket) else { return } + // A replaced SDK authority belongs to its new owner. Automatic retirement + // cleans only app-owned work; it never signs that replacement account out. + do { + if retryCredentials { try await service.completePendingCleanup() } + if intent == .signOut, !revocationCompleted { + try await service.signOut(ifOwnedBy: cleanupIdentity) + revocationCompleted = true + } + if await service.authorityState()?.requiresCleanup == true { failed = true } + } catch { failed = true } + if let scope { + do { try await storage.retire(scope) } catch { failed = true } + } + do { try await storage.clearSignedOutData() } catch { failed = true } + guard owns(ticket) else { return } + if failed { + phase = .unavailable; issue = .cleanup + } else { + cleanupScope = nil; cleanupIntent = nil; cleanupIdentity = nil; revocationCompleted = false + // Resolve the actual current authority after all cleanup awaits. A + // replacement installed during retirement must not inherit old data. + phase = .restoring + await resolve(ticket) + } + } + + /// Refresh verification/profile data without changing the application's + /// account generation or taking ownership of another sign-in's result. + public func refreshUser() async { + guard phase == .signedIn, let owned = account, let identity = accountIdentity else { return } + do { + guard try await service.identity() == identity, account?.scope == owned.scope else { return } + let user = try await service.user() + guard try await service.identity() == identity, account?.scope == owned.scope, + user.id == owned.scope.principalID + else { return } + account = AccountSession(scope: owned.scope, user: user) + } catch { + // Profile refresh must not revoke authority or discard offline work. + } + } + + private func owns(_ ticket: UUID) -> Bool { operation == ticket } + + private static func present(_ error: Error) -> SessionIssue { + if let clock = AttestationClockIssue.classify(error) { return .clock(clock) } + if error is ChatStorageError { return .storage } + if let error = error as? ResearchAPIError { + switch error { + case .invalidArgument: return .invalidInput + case .http(_, .invalidLogin): return .credentials + case .http(_, .emailAlreadyRegistered): return .emailAlreadyRegistered + default: break + } + } + return .connection + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Chat/ChatContracts.swift b/apps/maple-ios/Sources/MapleAppCore/Chat/ChatContracts.swift new file mode 100644 index 000000000..34f2ab5ee --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Chat/ChatContracts.swift @@ -0,0 +1,185 @@ +import Foundation +import MapleSDK + +public protocol ChatServing: Sendable { + func createConversation(metadata: [String: JSONValue]?, projectId: String?, pinned: Bool?) async throws + -> Conversation + func listConversations(_ parameters: ConversationsListParameters) async throws -> APIPage + func createResponse(_ request: ResponsesCreateRequest, maximumInferenceSends: Int) async throws + -> AsyncThrowingStream + func retrieveResponse(id: String) async throws -> ResearchResponse + func cancelResponse(id: String) async throws -> ResearchResponse + func getConversationItem(conversationId: String, itemId: String) async throws -> ConversationItem + func listConversationItems(conversationId: String, parameters: ListParameters) async throws -> APIPage< + ConversationItem + > +} + +/// Implementations serialize saves by revision and reject writes from retired +/// account generations, including writes already waiting when retirement begins. +public protocol ChatPersistence: Sendable { + func load(chatID: String, scope: AccountScope) async throws -> ChatSnapshot? + func loadAll(scope: AccountScope) async throws -> [ChatSnapshot] + func save(_ snapshot: ChatSnapshot, scope: AccountScope) async throws + func remove(chatID: String, scope: AccountScope) async throws + func rekey(from: String, to: ChatSnapshot, scope: AccountScope) async throws +} + +public struct ChatImageAttachment: Codable, Sendable, Equatable, Identifiable { + public var id: UUID + public var data: Data + public var contentType: String + + public init(id: UUID = UUID(), data: Data, contentType: String) { + self.id = id; self.data = data; self.contentType = contentType + } +} + +public struct ChatDraft: Codable, Sendable, Equatable, Identifiable { + public var id: UUID + public var text: String + public var images: [ChatImageAttachment] + + public init(id: UUID = UUID(), text: String = "", images: [ChatImageAttachment] = []) { + self.id = id; self.text = text; self.images = images + } + + public var isEmpty: Bool { text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && images.isEmpty } +} + +public struct ChatSendOptions: Codable, Sendable, Equatable { + public var model: String + public var webSearch: Bool + public var projectID: String? + /// Effective catalog-supported choice captured when this turn is submitted. + public var reasoningEffort: ReasoningEffort? + + public init( + model: String = "auto:quick", webSearch: Bool = false, projectID: String? = nil, + reasoningEffort: ReasoningEffort? = nil + ) { + self.model = model; self.webSearch = webSearch; self.projectID = projectID + self.reasoningEffort = reasoningEffort + } +} + +public enum ChatRunPhase: String, Codable, Sendable { case preparing, sending, streaming, recovering, stopping } + +public struct ChatPendingRun: Codable, Sendable, Equatable, Identifiable { + public var id: UUID + public var messageID: String + public var outgoingDraft: ChatDraft + public var responseID: String? + public var phase: ChatRunPhase + public var stopRequested: Bool + public var dispatched: Bool + public var options: ChatSendOptions + public var queuedTurn: ChatQueuedTurn? + /// A distinct, durable create request owner, separate from the Responses UUID. + public var creationID: String? + public var creationDispatched: Bool? + + public init(id: UUID, messageID: String, outgoingDraft: ChatDraft, options: ChatSendOptions) { + self.id = id; self.messageID = messageID; self.outgoingDraft = outgoingDraft + responseID = nil; phase = .preparing; stopRequested = false; dispatched = false; self.options = options + } +} + +public struct ChatSnapshot: Codable, Sendable, Equatable { + public static let newChatKey = "new-chat" + public var storageKey: String + /// Stable across composer changes, editing and run settlement. Older + /// snapshots adopt their original draft ID before the first mutation. + public var storageOwnerID: UUID? + public var storageOwner: UUID { storageOwnerID ?? draft.id } + public var conversationID: String? + public var draft: ChatDraft + public var pendingRun: ChatPendingRun? + /// A provably rejected turn remains available if the user has already typed + /// a different composer draft. Restoring it must never overwrite that draft. + public var unsentTurn: ChatDraft? + public var queue: ChatQueueState? + public var revision: UInt64 + + public init( + storageKey: String = Self.newChatKey, storageOwnerID: UUID? = nil, conversationID: String? = nil, + draft: ChatDraft = ChatDraft(), pendingRun: ChatPendingRun? = nil, + unsentTurn: ChatDraft? = nil, queue: ChatQueueState? = nil, revision: UInt64 = 0 + ) { + self.storageKey = storageKey; self.conversationID = conversationID; self.draft = draft + self.storageOwnerID = storageOwnerID ?? draft.id + self.pendingRun = pendingRun; self.unsentTurn = unsentTurn; self.queue = queue; self.revision = revision + } +} + +/// One successful durable settlement, retaining its original turn ownership. +public struct ChatCompletion: Sendable { + public let scope: AccountScope + public let runID: UUID + public let conversationID: String + public let messageID: String + public let responseID: String? + public let assistantItems: [ConversationItem] + public let recovered: Bool + public let wasStopped: Bool + public let wasVisible: Bool + public let wasForeground: Bool + public var assistantText: String { + var text: [String] = [] + for item in assistantItems { + for part in item.content ?? [] { + if part.type == "output_text" || part.type == "text", let value = part.text { + text.append(value) + } + } + } + return text.joined(separator: "\n") + } +} + +public enum ChatOutcome: String, Sendable { case completed, cancelled, failed, rejected } + +/// An explicit Send or Enqueue accepted into protected local state. It is never +/// recreated by queue promotion, reconciliation or process restoration. +public struct ChatSendAcceptance: Sendable, Equatable { + public let scope: AccountScope + public let storageOwnerID: UUID + public let draftID: UUID + public let messageID: String + public let conversationID: String? +} + +/// Presentation maps these categories to localized strings. No provider body, +/// prompt, token, callback or underlying error description crosses this boundary. +public enum ChatIssue: String, Error, Sendable { + case offline, busy, emptyDraft, invalidImages, draftTooLarge, storageUnavailable + case conversationUnavailable, conversationDeleting, uncertainResponse, responseUnavailable, cancellationUnavailable + case historyUnavailable, requestRejected, contextLimit, freeTierTokenLimit, usageLimit, modelUnavailable, signedOut + case unsupportedValue, imageDescriptionUnavailable, inferenceCapacityUnavailable, generationFailed + case stoppedBeforeSending, stoppingWhenResponseReady, responseFinishedBeforeStop, recoveredCompletion, recoveredStop + case queueLimit, unfinishedQueueEdit, draftInUse +} + +public struct ChatRuntimeHooks: Sendable { + public var uuid: @Sendable () -> UUID + public var sleep: @Sendable (Duration) async throws -> Void + + public init( + uuid: @escaping @Sendable () -> UUID = UUID.init, + sleep: (@Sendable (Duration) async throws -> Void)? = nil + ) { + self.uuid = uuid; self.sleep = sleep ?? Self.delay + } + + private static func delay(_ duration: Duration) async throws { + try Task.checkCancellation() + guard duration > .zero else { return } + let parts = duration.components + let (seconds, overflow) = UInt64(clamping: parts.seconds).multipliedReportingOverflow(by: 1_000_000_000) + let (nanoseconds, additionOverflow) = seconds.addingReportingOverflow( + UInt64(clamping: parts.attoseconds / 1_000_000_000)) + // Use the non-generic cancellable primitive to avoid cross-module + // Clock specialization failures in the pinned Apple toolchain. + try await Task.sleep(nanoseconds: overflow || additionOverflow ? UInt64.max : nanoseconds) + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Chat/ChatQueue.swift b/apps/maple-ios/Sources/MapleAppCore/Chat/ChatQueue.swift new file mode 100644 index 000000000..a7f9d5676 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Chat/ChatQueue.swift @@ -0,0 +1,83 @@ +import Foundation + +public struct ChatQueuedTurn: Codable, Sendable, Equatable, Identifiable { + public var id: UUID + public var messageID: String + public var draft: ChatDraft + public var options: ChatSendOptions + + public init( + id: UUID = UUID(), messageID: String = UUID().uuidString.lowercased(), + draft: ChatDraft, options: ChatSendOptions = ChatSendOptions() + ) { + self.id = id; self.messageID = messageID; self.draft = draft; self.options = options + } +} + +public struct ChatQueueEdit: Codable, Sendable, Equatable { + public var original: ChatQueuedTurn + public var originalIndex: Int + public var heldDraft: ChatDraft + + public init(original: ChatQueuedTurn, originalIndex: Int, heldDraft: ChatDraft) { + self.original = original; self.originalIndex = originalIndex; self.heldDraft = heldDraft + } +} + +public struct ChatQueueState: Codable, Sendable, Equatable { + public var items: [ChatQueuedTurn] + public var edit: ChatQueueEdit? + public var paused: Bool + /// A previously admitted active turn can return ahead of sixteen waiting + /// turns. This one recovery slot never admits an additional new message. + public var recoveredHeadID: UUID? + + public init( + items: [ChatQueuedTurn] = [], edit: ChatQueueEdit? = nil, paused: Bool = false, + recoveredHeadID: UUID? = nil + ) { + self.items = items; self.edit = edit; self.paused = paused + self.recoveredHeadID = recoveredHeadID + } +} + +extension ChatSnapshot { + /// Includes original, working and held drafts. Identical references may be + /// shared, but differing payload versions are never hidden by a reused ID. + public var ownedDrafts: [ChatDraft] { + var drafts = [draft] + if let outgoing = pendingRun?.outgoingDraft { drafts.append(outgoing) } + if let queued = pendingRun?.queuedTurn { drafts.append(queued.draft) } + if let unsentTurn { drafts.append(unsentTurn) } + drafts += queue?.items.map(\.draft) ?? [] + if let edit = queue?.edit { drafts += [edit.original.draft, edit.heldDraft] } + return drafts + } +} + +/// The account registry supplies every retained snapshot, substituting a +/// proposed transition for its current owner before admitting more resources. +public struct ChatResourceUsage: Sendable, Equatable { + public private(set) var retainedTurns = 0 + public private(set) var images = 0 + public private(set) var imageBytes = 0 + + public init(snapshots: [ChatSnapshot]) { + var turns = Set() + var imageVersions: [UUID: [ChatImageAttachment]] = [:] + for snapshot in snapshots { + for item in snapshot.queue?.items ?? [] { turns.insert(item.id) } + if let edited = snapshot.queue?.edit?.original { turns.insert(edited.id) } + if let queued = snapshot.pendingRun?.queuedTurn { turns.insert(queued.id) } + for draft in snapshot.ownedDrafts { + for image in draft.images where !(imageVersions[image.id]?.contains(image) ?? false) { + imageVersions[image.id, default: []].append(image) + images += 1; imageBytes += image.data.count + } + } + } + retainedTurns = turns.count + } + + public var fitsAccount: Bool { retainedTurns <= 64 && images <= 640 && imageBytes <= 256 * 1_024 * 1_024 } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Chat/ChatRequestBudget.swift b/apps/maple-ios/Sources/MapleAppCore/Chat/ChatRequestBudget.swift new file mode 100644 index 000000000..f23bc319c --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Chat/ChatRequestBudget.swift @@ -0,0 +1,49 @@ +import Foundation +import MapleSDK + +enum ChatRequestBudget { + enum Assessment { case fits, exceeds, needsEncoding } + static let maximum = 50 * 1_024 * 1_024 + + static func assess(_ draft: ChatDraft, options: ChatSendOptions, conversationID: String?) -> Assessment { + let text = draft.text.utf8.count + options.model.utf8.count + (conversationID?.utf8.count ?? 128) + guard text <= maximum else { return .exceeds } + var base64 = 0 + for image in draft.images { + guard image.data.count <= maximum else { return .exceeds } + base64 += 4 * ((image.data.count + 2) / 3) + } + guard text + base64 <= maximum else { return .exceeds } + // Every JSON string source byte is at most six escaped bytes. Each + // base64 slash may add one byte. Ambiguity is resolved off the UI actor. + return 64 * 1_024 + 6 * text + 2 * base64 <= maximum ? .fits : .needsEncoding + } + + static func exactFits(_ draft: ChatDraft, options: ChatSendOptions, conversationID: String?) async throws -> Bool { + let task = Task.detached { + try Task.checkCancellation() + var content: [ResponseInputContent] = [] + if !draft.text.isEmpty { content.append(.text(draft.text)) } + for image in draft.images { + try Task.checkCancellation() + content.append(.image(url: "data:\(image.contentType);base64," + image.data.base64EncodedString())) + } + let request = ResponsesCreateRequest( + model: options.model, input: .messages([ResponseInputMessage(content: content)]), + conversation: conversationID ?? String(repeating: "x", count: 128), + metadata: ["internal_message_id": .string("00000000-0000-0000-0000-000000000000")], + webSearch: options.webSearch, reasoningEffort: options.reasoningEffort) + let encoder = JSONEncoder() + // Matches SDK APIJSON.encode, including its default slash escaping. + encoder.keyEncodingStrategy = .convertToSnakeCase + let count = try encoder.encode(request).count + try Task.checkCancellation() + return count <= maximum + } + return try await withTaskCancellationHandler { + try await task.value + } onCancel: { + task.cancel() + } + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Chat/ChatRuntime.swift b/apps/maple-ios/Sources/MapleAppCore/Chat/ChatRuntime.swift new file mode 100644 index 000000000..64865550c --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Chat/ChatRuntime.swift @@ -0,0 +1,1536 @@ +import Foundation +import MapleSDK +import Observation + +/// Retain this owner in the account session when its screen is not visible. +/// Navigation only changes presentation eligibility; it never owns the run task. +@MainActor @Observable +public final class ChatRuntime { + public let scope: AccountScope + public private(set) var snapshot: ChatSnapshot + public private(set) var messages: [ConversationItem] = [] + public private(set) var lastIssue: ChatIssue? { + didSet { + lastIssueID = lastIssue == nil ? nil : hooks.uuid() + acknowledgedIssueID = nil + } + } + public private(set) var lastIssueID: UUID? + public private(set) var lastOutcome: ChatOutcome? + public private(set) var isLoading = false + public private(set) var isLoadingOlder = false + public private(set) var hasOlderMessages = false + /// Polling scroll requests are separate from streaming and older-page merges. + public private(set) var polledMessagesRevision: UInt64 = 0 + public var isOnline = true { + didSet { + if isOnline != oldValue { itemPollID = nil } + if !isOnline { pauseQueue() } + } + } + public var isVisible = false { didSet { if isVisible != oldValue { itemPollID = nil } } } + public var isForeground = true { didSet { if isForeground != oldValue { itemPollID = nil } } } + public var onConversationAssigned: (@MainActor (String) -> Void)? + public var onCompletion: (@MainActor (ChatCompletion) -> Void)? + public var onTurnAccepted: (@MainActor (ChatSendAcceptance) -> Void)? + public var onSuccessfulCompletion: (@MainActor (String) -> Void)? + public var onSuccessfulVisibleCompletion: (@MainActor (String) -> Void)? + /// Queue admission and later promotion are distinct ownership transitions. + /// Unread completion belongs to the latest actually started run. + public var onRunStarted: (@MainActor () -> Void)? + public var canAdmitSnapshot: (@MainActor (ChatSnapshot) -> Bool)? + public var onDraftOwnerChanging: (@MainActor (UUID) -> Void)? + public var onDraftOwnerRetired: (@MainActor (UUID) -> Void)? + public var onQuiesceForDeletion: (@MainActor () -> Void)? + /// Discovery can temporarily fence an unrelated selected conversation. + /// Its presentation owner restarts refreshes when that fence is released. + public var onDeletionFenceChanged: (@MainActor () -> Void)? + /// Recording/transcription blocks only submission of its own draft owner. + public var busyDraftIDs: Set = [] + public private(set) var deletionFenceIDs: Set = [] + @ObservationIgnored private var quiescingFenceIDs: Set = [] + var deletionAdmission: (@MainActor (ChatSendOptions) -> Bool)? + + @ObservationIgnored private var runOutputIDs: [UUID: Set] = [:] + @ObservationIgnored private let service: any ChatServing + @ObservationIgnored private let persistence: any ChatPersistence + @ObservationIgnored private let writes: ChatWriteLane + @ObservationIgnored private let hooks: ChatRuntimeHooks + @ObservationIgnored private var valid = true + @ObservationIgnored private var operationID = UUID() + @ObservationIgnored private var runTask: Task? + @ObservationIgnored private var cancelTask: Task? + @ObservationIgnored private var autosaveTask: Task? + @ObservationIgnored private var oldestItemID: String? + @ObservationIgnored private var hasLoadedInitialHistory = false + @ObservationIgnored private var paginationID: UUID? + @ObservationIgnored private var newestSeenItemID: String? + @ObservationIgnored private var itemPollID: UUID? + @ObservationIgnored private var pendingRekeyFrom: String? + @ObservationIgnored private var waitingForCapacity = false + @ObservationIgnored private var detachmentSource: ChatSnapshot? + @ObservationIgnored private var detachmentDestination: ChatSnapshot? + @ObservationIgnored private var detachmentTask: Task? + @ObservationIgnored private var detachmentID: UUID? + @ObservationIgnored private var submissionTask: Task? + @ObservationIgnored private var submissionID: UUID? + @ObservationIgnored private var submissionDraftID: UUID? + @ObservationIgnored private var creationInFlight: UUID? + @ObservationIgnored private var deletionRecoveryTask: Task? + @ObservationIgnored private var deletedStorageKey: String? + @ObservationIgnored private var pendingAcceptances: [ChatSendAcceptance] = [] + @ObservationIgnored private var acknowledgedIssueID: UUID? + + public init( + scope: AccountScope, service: any ChatServing, persistence: any ChatPersistence, + conversationID: String? = nil, hooks: ChatRuntimeHooks = ChatRuntimeHooks() + ) { + self.scope = scope; self.service = service; self.persistence = persistence; self.hooks = hooks + snapshot = ChatSnapshot( + storageKey: conversationID ?? ChatSnapshot.newChatKey, conversationID: conversationID, + draft: ChatDraft(id: hooks.uuid())) + writes = ChatWriteLane(persistence: persistence, scope: scope) + } + + public var conversationID: String? { snapshot.conversationID } + public var isRetired: Bool { !valid } + public var phase: ChatRunPhase? { snapshot.pendingRun?.phase ?? (submissionID == nil ? nil : .preparing) } + public var hasUnresolvedRun: Bool { snapshot.pendingRun != nil || submissionID != nil } + public var queuedTurns: [ChatQueuedTurn] { snapshot.queue?.items ?? [] } + public var isEditingQueue: Bool { snapshot.queue?.edit != nil } + public var isQueuePaused: Bool { snapshot.queue?.paused ?? false } + public var canSend: Bool { + valid && deletionFenceIDs.isEmpty && !isLoading && isOnline && !isEditingQueue && submissionID == nil + && !busyDraftIDs.contains(snapshot.draft.id) + && !snapshot.draft.isEmpty + } + public var draftText: String { + get { snapshot.draft.text } + set { + guard valid, !isLoading else { return } + snapshot.draft.text = newValue; changed(); scheduleAutosave() + } + } + + /// Presentation acknowledgment belongs to this account-owned runtime, so + /// re-mounting its screen cannot present the same issue occurrence again. + public func pendingIssuePresentationID(for issue: ChatIssue) -> UUID? { + guard valid, lastIssue == issue, let id = lastIssueID, acknowledgedIssueID != id else { return nil } + return id + } + + @discardableResult + public func acknowledgeIssuePresentation(id: UUID) -> Bool { + guard valid, lastIssueID == id, lastIssue != nil, acknowledgedIssueID != id else { return false } + acknowledgedIssueID = id + return true + } + + public func updateDraft(_ draft: ChatDraft) { + guard valid, !isLoading, draft.id == snapshot.draft.id else { return } + snapshot.draft = draft; changed(); scheduleAutosave() + } + + public func ownedDraft(id: UUID) -> ChatDraft? { + if snapshot.draft.id == id { return snapshot.draft } + if snapshot.queue?.edit?.heldDraft.id == id { return snapshot.queue?.edit?.heldDraft } + return nil + } + + /// Pending dictation may update held B while A is in the editor, but never + /// a committed queued entry, an outgoing turn, or a retired edit owner. + @discardableResult + public func updateOwnedDraft(_ draft: ChatDraft) -> Bool { + guard valid, !isLoading else { return false } + if draft.id == snapshot.draft.id { updateDraft(draft); return true } + guard snapshot.queue?.edit?.heldDraft.id == draft.id else { return false } + snapshot.queue?.edit?.heldDraft = draft; changed(); scheduleAutosave(); return true + } + + @discardableResult + public func enqueueDraft(options: ChatSendOptions = ChatSendOptions()) -> Bool { + enqueueDraft(options: options, bodyValidated: false) + } + + private func enqueueDraft(options: ChatSendOptions, bodyValidated: Bool) -> Bool { + guard deletionFenceIDs.isEmpty, deletionAdmission?(options) ?? true else { + lastIssue = .conversationDeleting; return false + } + guard valid, !isLoading, !isEditingQueue else { lastIssue = .unfinishedQueueEdit; return false } + guard submissionID == nil else { lastIssue = .busy; return false } + guard !busyDraftIDs.contains(snapshot.draft.id) else { lastIssue = .draftInUse; return false } + guard !snapshot.draft.isEmpty else { lastIssue = .emptyDraft; return false } + guard snapshot.draft.text.utf8.count <= 32 * 1_024 else { lastIssue = .draftTooLarge; return false } + guard Self.validImages(snapshot.draft.images) else { lastIssue = .invalidImages; return false } + if !bodyValidated { + let draft = snapshot.draft + switch checkBody( + draft, options: options, isCurrent: { [weak self] in self?.snapshot.draft == draft }, + onValid: { [weak self] in + guard let self, snapshot.draft == draft else { return } + _ = enqueueDraft(options: options, bodyValidated: true) + }) + { + case .fits: break + case .rejected: return false + case .reserved: return true + } + } + var proposed = snapshot + var queue = proposed.queue ?? ChatQueueState() + guard queue.items.count < 16 else { lastIssue = .queueLimit; return false } + let accepted = ChatQueuedTurn( + id: hooks.uuid(), messageID: hooks.uuid().uuidString.lowercased(), + draft: proposed.draft, options: options) + queue.items.append(accepted) + if !isOnline || phase == .recovering || snapshot.pendingRun?.stopRequested == true || !hasUnresolvedRun { + queue.paused = true + } + proposed.queue = queue; proposed.draft = ChatDraft(id: hooks.uuid()) + guard admits(proposed) else { lastIssue = .queueLimit; return false } + onDraftOwnerChanging?(snapshot.draft.id) + snapshot = proposed; changed(); lastIssue = nil + recordAcceptance(draftID: accepted.draft.id, messageID: accepted.messageID) + scheduleAutosave(); return true + } + + @discardableResult + public func beginQueueEdit(id: UUID) -> Bool { + guard valid, !isLoading, !isEditingQueue, var queue = snapshot.queue, + let index = queue.items.firstIndex(where: { $0.id == id }) + else { return false } + onDraftOwnerChanging?(snapshot.draft.id) + let original = queue.items[index] + queue.edit = ChatQueueEdit(original: original, originalIndex: index, heldDraft: snapshot.draft) + snapshot.queue = queue + snapshot.draft = ChatDraft(id: hooks.uuid(), text: original.draft.text, images: original.draft.images) + changed(); lastIssue = nil; scheduleAutosave(); return true + } + + @discardableResult + public func saveQueueEdit(options: ChatSendOptions? = nil) -> Bool { + saveQueueEdit(options: options, bodyValidated: false) + } + + private func saveQueueEdit(options: ChatSendOptions?, bodyValidated: Bool) -> Bool { + guard valid, let edit = snapshot.queue?.edit, var queue = snapshot.queue, + let index = queue.items.firstIndex(where: { $0.id == edit.original.id }) + else { return false } + guard !busyDraftIDs.contains(snapshot.draft.id) else { lastIssue = .draftInUse; return false } + guard submissionID == nil else { lastIssue = .busy; return false } + let options = options ?? edit.original.options + guard !snapshot.draft.isEmpty else { lastIssue = .emptyDraft; return false } + guard snapshot.draft.text.utf8.count <= 32 * 1_024, Self.validImages(snapshot.draft.images) + else { lastIssue = .draftTooLarge; return false } + if !bodyValidated { + let draft = snapshot.draft + switch checkBody( + draft, options: options, isCurrent: { [weak self] in self?.snapshot.draft == draft }, + onValid: { [weak self] in + guard let self, snapshot.draft == draft, snapshot.queue?.edit?.original.id == edit.original.id + else { return } + _ = saveQueueEdit(options: options, bodyValidated: true) + }) + { + case .fits: break + case .rejected: return false + case .reserved: return true + } + } + var item = edit.original + item.draft = ChatDraft(id: hooks.uuid(), text: snapshot.draft.text, images: snapshot.draft.images) + item.options = options; queue.items[index] = item; queue.edit = nil + var proposed = snapshot; proposed.queue = queue; proposed.draft = edit.heldDraft + guard admits(proposed) else { lastIssue = .queueLimit; return false } + retireVisibleEdit() + snapshot = proposed; changed(); lastIssue = nil; scheduleAutosave(); promoteIfEligible(); return true + } + + public func cancelQueueEdit() { + guard valid, let edit = snapshot.queue?.edit else { return } + retireVisibleEdit() + snapshot.draft = edit.heldDraft; snapshot.queue?.edit = nil + changed(); lastIssue = nil; scheduleAutosave(); promoteIfEligible() + } + + public func deleteQueuedTurn(id: UUID) { + guard valid, var queue = snapshot.queue, let index = queue.items.firstIndex(where: { $0.id == id }) else { + return + } + if let edit = queue.edit, edit.original.id == id { + retireVisibleEdit(); snapshot.draft = edit.heldDraft; queue.edit = nil + } else if let edit = queue.edit, index < edit.originalIndex { + queue.edit?.originalIndex -= 1 + } + queue.items.remove(at: index) + if queue.items.count <= 16 || queue.recoveredHeadID == id { queue.recoveredHeadID = nil } + snapshot.queue = queue + changed(); scheduleAutosave(); promoteIfEligible() + } + + public func pauseQueue() { + guard valid, snapshot.queue != nil else { return } + snapshot.queue?.paused = true; changed(); scheduleAutosave() + } + + @discardableResult + public func resumeQueue() -> Bool { + guard valid, deletionFenceIDs.isEmpty, isOnline, !isEditingQueue, !hasUnresolvedRun, !queuedTurns.isEmpty else { + return false + } + snapshot.queue?.paused = false; changed(); scheduleAutosave(); promoteIfEligible(); return true + } + + private func retireVisibleEdit() { + let owner = snapshot.draft.id + onDraftOwnerChanging?(owner); busyDraftIDs.remove(owner); onDraftOwnerRetired?(owner) + } + + private func admits(_ proposed: ChatSnapshot) -> Bool { + ChatResourceUsage(snapshots: [proposed]).fitsAccount && (canAdmitSnapshot?(proposed) ?? true) + } + + private func promoteIfEligible(bodyValidated: Bool = false) { + guard valid, deletionFenceIDs.isEmpty, !isLoading, isOnline, !hasUnresolvedRun, !isEditingQueue, + var queue = snapshot.queue, !queue.paused, let next = queue.items.first + else { return } + guard deletionAdmission?(next.options) ?? true else { return } + guard !busyDraftIDs.contains(next.draft.id) else { return } + guard !next.draft.isEmpty, next.draft.text.utf8.count <= 32 * 1_024, Self.validImages(next.draft.images) else { + snapshot.queue?.paused = true; lastIssue = .storageUnavailable; changed(); scheduleAutosave(); return + } + guard let messageUUID = UUID(uuidString: next.messageID), messageUUID.uuidString.lowercased() == next.messageID + else { + snapshot.queue?.paused = true; lastIssue = .storageUnavailable; changed(); scheduleAutosave(); return + } + if !bodyValidated { + switch checkBody( + next.draft, options: next.options, isCurrent: { [weak self] in self?.queuedTurns.first == next }, + pauseQueueOnFailure: true, + onValid: { [weak self] in + guard let self, queuedTurns.first == next else { return } + promoteIfEligible(bodyValidated: true) + }) + { + case .fits: break + case .reserved: return + case .rejected: pauseQueue(); return + } + } + autosaveTask?.cancel() + queue.items.removeFirst() + if queue.items.count <= 16 || queue.recoveredHeadID == next.id { queue.recoveredHeadID = nil } + var run = ChatPendingRun( + id: hooks.uuid(), messageID: next.messageID, outgoingDraft: next.draft, + options: next.options) + run.queuedTurn = next + snapshot.queue = queue; snapshot.pendingRun = run; changed() + detachNewChatIfNeeded(runID: run.id) + messages.append(Self.userItem(run)); lastIssue = nil; lastOutcome = nil + let operation = hooks.uuid(); operationID = operation; waitingForCapacity = false + runTask = Task { await execute(runID: run.id, operation: operation) } + onRunStarted?() + } + + /// Exchanges two complete drafts rather than losing edits made during a send. + public func restoreUnsentTurn() { + guard valid, !hasUnresolvedRun, !isEditingQueue, !busyDraftIDs.contains(snapshot.draft.id), + let unsent = snapshot.unsentTurn + else { return } + onDraftOwnerChanging?(snapshot.draft.id) + snapshot.unsentTurn = snapshot.draft.isEmpty ? nil : snapshot.draft + snapshot.draft = unsent; changed(); scheduleAutosave() + } + + /// Releases the shared New Chat slot only after its outgoing owner has a + /// durable unique key. A registry must await this before loading a fresh slot. + public func prepareForNewChat() async -> Bool { + await submissionTask?.value + guard valid, snapshot.storageKey != ChatSnapshot.newChatKey else { return false } + do { + try await ensureDetached() + return valid + } catch { + if valid { lastIssue = .storageUnavailable } + return false + } + } + + /// Restores a discovered pre-conversation owner without creating a chat or + /// sending anything. Explicit reconciliation can return its unsent draft. + @discardableResult + public func loadRecovered(_ restored: ChatSnapshot) -> Bool { + guard valid, !isLoading, !hasUnresolvedRun, + restored.conversationID == nil, restored.storageKey.hasPrefix("pending:") + else { return false } + return loadRetainedSnapshot(restored) + } + + /// Restores an already validated protected snapshot for account-wide + /// ownership/deletion without fetching history or dispatching network work. + @discardableResult public func loadRetainedSnapshot(_ restored: ChatSnapshot) -> Bool { + guard valid, !isLoading, !hasUnresolvedRun, + restored.conversationID.map({ $0 == restored.storageKey }) + ?? (restored.storageKey == ChatSnapshot.newChatKey || restored.storageKey.hasPrefix("pending:")) + else { return false } + autosaveTask?.cancel(); operationID = hooks.uuid(); itemPollID = nil; newestSeenItemID = nil + hasLoadedInitialHistory = false + snapshot = restored + snapshot.storageOwnerID = restored.storageOwner + snapshot.pendingRun?.phase = .recovering; changed() + snapshot.queue?.paused = true + messages = restored.pendingRun.map { [Self.userItem($0)] } ?? [] + lastIssue = nil; lastOutcome = nil + return true + } + + /// A cold account launch calls load(nil). Existing interrupted work is loaded + /// only when its conversation is opened, and is never automatically replayed. + public func load(chatID: String?) async { + guard valid, deletionFenceIDs.isEmpty, !hasUnresolvedRun else { return } + autosaveTask?.cancel() + paginationID = nil; isLoadingOlder = false; itemPollID = nil; newestSeenItemID = nil + hasLoadedInitialHistory = false + let operation = hooks.uuid(); operationID = operation; waitingForCapacity = false; isLoading = true + let key = chatID ?? ChatSnapshot.newChatKey + var loadingStorage = true + snapshot = ChatSnapshot(storageKey: key, conversationID: chatID, draft: ChatDraft(id: hooks.uuid())) + messages = []; oldestItemID = nil; hasOlderMessages = false; lastIssue = nil; lastOutcome = nil + do { + let restored = try await persistence.load(chatID: key, scope: scope) + guard owns(operation) else { return } + if var restored { + guard restored.storageKey == key, restored.conversationID == chatID else { + throw ChatIssue.storageUnavailable + } + if restored.storageOwnerID == nil { + restored.storageOwnerID = restored.storageOwner; restored.revision &+= 1 + } + if restored.pendingRun != nil { + restored.pendingRun?.phase = .recovering + restored.revision &+= 1 + } + if restored.queue != nil { restored.queue?.paused = true; restored.revision &+= 1 } + snapshot = restored + if let run = restored.pendingRun { messages = [Self.userItem(run)] } + } + loadingStorage = false + if let chatID { + let page = try await service.listConversationItems( + conversationId: chatID, parameters: ListParameters(limit: 20, order: .desc)) + guard owns(operation) else { return } + let optimistic = messages + messages = Array(page.data.reversed()) + for item in optimistic where !messages.contains(where: { $0.id == item.id }) { messages.append(item) } + oldestItemID = page.lastId; hasOlderMessages = page.hasMore + hasLoadedInitialHistory = true + newestSeenItemID = page.data.first { $0.status != "in_progress" }?.id + } + } catch { + if owns(operation) { lastIssue = loadingStorage ? .storageUnavailable : .historyUnavailable } + } + guard owns(operation) else { return } + isLoading = false + if hasUnresolvedRun { await reconcile() } + } + + public func loadOlderMessages() async { + guard !Task.isCancelled, valid, !isLoading, !isLoadingOlder, hasOlderMessages, let chatID = conversationID, + let cursor = oldestItemID + else { + return + } + let operation = operationID, request = hooks.uuid() + paginationID = request; isLoadingOlder = true + defer { + if paginationID == request { paginationID = nil; isLoadingOlder = false } + } + do { + let page = try await service.listConversationItems( + conversationId: chatID, parameters: ListParameters(limit: 20, after: cursor, order: .desc)) + guard !Task.isCancelled, owns(operation), conversationID == chatID, paginationID == request else { return } + let existing = Set(messages.map(\.id)) + messages.insert(contentsOf: page.data.reversed().filter { !existing.contains($0.id) }, at: 0) + oldestItemID = page.lastId; hasOlderMessages = page.hasMore + } catch is CancellationError { + // Navigation cancels its page read without changing retained chat feedback. + } catch { + if !Task.isCancelled, owns(operation), paginationID == request { lastIssue = .historyUnavailable } + } + } + + /// Poll only the selected, presented conversation. Failed refreshes leave + /// transcript, run ownership and existing feedback untouched. + public func pollNewItems() async { + guard canPollItems, itemPollID == nil, let chatID = conversationID else { return } + let operation = operationID, request = hooks.uuid(), cursor = newestSeenItemID + let priorHistoryIssueID = lastIssue == .historyUnavailable ? lastIssueID : nil + itemPollID = request + defer { if itemPollID == request { itemPollID = nil } } + do { + let page = try await service.listConversationItems( + conversationId: chatID, + parameters: ListParameters(limit: 20, after: cursor, order: cursor == nil ? .desc : .asc)) + guard owns(operation), canPollItems, !Task.isCancelled, conversationID == chatID, + itemPollID == request + else { return } + let chronological = cursor == nil ? Array(page.data.reversed()) : page.data + let retainedOrder = messages.map(\.id), existing = Set(retainedOrder) + mergeHistory(chronological, authoritative: false) + if cursor == nil, !hasLoadedInitialHistory { + // A failed initial read can leave only an optimistic turn. Put + // recovered history before it without discarding richer local + // payloads or moving a local user behind its checkpointed reply. + let merged = Dictionary(messages.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + let fetchedIDs = Set(chronological.map(\.id)) + var included: Set = [] + var orderedIDs = chronological.compactMap { included.insert($0.id).inserted ? $0.id : nil } + for (index, id) in retainedOrder.enumerated() where included.insert(id).inserted { + let next = retainedOrder.dropFirst(index + 1).first { fetchedIDs.contains($0) } + let destination = next.flatMap { orderedIDs.firstIndex(of: $0) } ?? orderedIDs.endIndex + orderedIDs.insert(id, at: destination) + } + messages = orderedIDs.compactMap { merged[$0] } + oldestItemID = page.lastId; hasOlderMessages = page.hasMore + hasLoadedInitialHistory = true + if let priorHistoryIssueID, lastIssue == .historyUnavailable, lastIssueID == priorHistoryIssueID { + lastIssue = nil + } + } + // An in-progress tail must be fetched again on the next tick. + newestSeenItemID = chronological.last { $0.status != "in_progress" }?.id ?? newestSeenItemID + if chronological.contains(where: { !existing.contains($0.id) }) { polledMessagesRevision &+= 1 } + } catch { + // Polling is background refresh, never run failure or composer feedback. + } + } + + private var canPollItems: Bool { + valid && isVisible && isForeground && isOnline && !isLoading && deletionFenceIDs.isEmpty + && !Task.isCancelled + && !(snapshot.pendingRun?.responseID != nil && (phase == .streaming || phase == .stopping)) + } + + /// Admission is synchronous so two taps cannot detach the same draft. The + /// account-owned task survives a view disappearing or its button task ending. + @discardableResult + public func send(options: ChatSendOptions = ChatSendOptions()) -> Bool { + send(options: options, bodyValidated: false) + } + + private func send(options: ChatSendOptions, bodyValidated: Bool) -> Bool { + guard valid else { return false } + guard deletionFenceIDs.isEmpty, deletionAdmission?(options) ?? true else { + lastIssue = .conversationDeleting; return false + } + guard submissionID == nil else { lastIssue = .busy; return false } + guard !isLoading else { lastIssue = .busy; return false } + guard !isEditingQueue else { lastIssue = .unfinishedQueueEdit; return false } + guard !busyDraftIDs.contains(snapshot.draft.id) else { lastIssue = .draftInUse; return false } + guard isOnline else { lastIssue = .offline; return false } + if hasUnresolvedRun || !queuedTurns.isEmpty { return enqueueDraft(options: options) } + guard !snapshot.draft.isEmpty else { lastIssue = .emptyDraft; return false } + guard Self.validImages(snapshot.draft.images) else { lastIssue = .invalidImages; return false } + if !bodyValidated { + let draft = snapshot.draft + switch checkBody( + draft, options: options, isCurrent: { [weak self] in self?.snapshot.draft == draft }, + onValid: { [weak self] in + guard let self, snapshot.draft == draft else { return } + _ = send(options: options, bodyValidated: true) + }) + { + case .fits: break + case .rejected: return false + case .reserved: return true + } + } + autosaveTask?.cancel() + let run = ChatPendingRun( + id: hooks.uuid(), messageID: hooks.uuid().uuidString.lowercased(), outgoingDraft: snapshot.draft, + options: options) + var proposed = snapshot; proposed.pendingRun = run; proposed.draft = ChatDraft(id: hooks.uuid()) + // A fresh direct run owns a new queue. An empty retained queue may + // still carry the pause from Stop, recovery, reload or going offline. + if proposed.queue?.items.isEmpty == true, proposed.queue?.edit == nil { proposed.queue = nil } + guard admits(proposed) else { lastIssue = .queueLimit; return false } + onDraftOwnerChanging?(snapshot.draft.id) + snapshot = proposed; changed() + recordAcceptance(draftID: run.outgoingDraft.id, messageID: run.messageID) + detachNewChatIfNeeded(runID: run.id) + messages.append(Self.userItem(run)); lastIssue = nil; lastOutcome = nil + let operation = hooks.uuid(); operationID = operation; waitingForCapacity = false + runTask = Task { await execute(runID: run.id, operation: operation) } + onRunStarted?() + return true + } + + public func stop() { + cancelSubmission() + pauseQueue() + guard valid, var run = snapshot.pendingRun else { return } + run.stopRequested = true; run.phase = .stopping; snapshot.pendingRun = run; changed() + scheduleAutosave() + if waitingForCapacity { runTask?.cancel(); return } + if run.dispatched, run.responseID == nil { lastIssue = .stoppingWhenResponseReady } + if run.dispatched, run.responseID != nil { + startCancellation(runID: run.id, operation: operationID) + } + // A pre-ID request retains its exact message owner. Its late created + // event or explicit reconciliation will carry Stop to the server. + else if run.dispatched, runTask == nil { + Task { await reconcile() } + } + } + + /// Lifecycle events reconcile already-owned uncertain work. Reopening a + /// healthy live stream leaves its owner running; foreground recovery may + /// replace a suspended stream with one bounded authoritative lookup. + public func reconcileOnLifecycle(foreground: Bool) async { + guard valid, isOnline, !Task.isCancelled, deletionFenceIDs.isEmpty, + let pending = snapshot.pendingRun, submissionID == nil, creationInFlight == nil, + runTask == nil || (foreground && pending.dispatched) + else { return } + await reconcile() + } + + /// One bounded reconciliation pass. Foreground/visible polling may call it + /// again for a pending result; it never calls createResponse. + public func reconcile() async { + guard valid, let run = snapshot.pendingRun, !isLoading, creationInFlight == nil else { return } + pauseQueue() + let operation = hooks.uuid(); operationID = operation; waitingForCapacity = false + runTask?.cancel(); runTask = nil; cancelTask?.cancel(); cancelTask = nil + let task = Task { await recover(runID: run.id, operation: operation) } + runTask = task + await task.value + if owns(operation) { runTask = nil } + } + + private func recover(runID: UUID, operation: UUID) async { + guard owns(runID, operation), let run = snapshot.pendingRun else { return } + snapshot.pendingRun?.phase = run.stopRequested ? .stopping : .recovering; changed() + if conversationID == nil, run.creationDispatched == true { + do { + guard let creationID = run.creationID, + let conversation = try await findCreatedConversation(creationID, projectID: run.options.projectID) + else { lastIssue = .uncertainResponse; return } + guard owns(runID, operation) else { return } + try await adoptConversation(conversation.id, runID: runID, operation: operation) + guard owns(runID, operation) else { return } + } catch { if owns(runID, operation) { lastIssue = .uncertainResponse }; return } + } + guard run.dispatched else { + await restoreRejected(runID: run.id, operation: operation, issue: .requestRejected) + return + } + guard let chatID = conversationID else { lastIssue = .uncertainResponse; return } + do { + var responseID = run.responseID + if responseID == nil { + let item = try await service.getConversationItem(conversationId: chatID, itemId: run.messageID) + guard owns(run.id, operation) else { return } + guard item.id == run.messageID, item.role == "user", let linked = item.responseId, !linked.isEmpty + else { + throw ChatIssue.uncertainResponse + } + responseID = linked; snapshot.pendingRun?.responseID = linked; changed() + try await persistCurrent() + guard owns(run.id, operation) else { return } + } + guard let responseID else { throw ChatIssue.uncertainResponse } + if snapshot.pendingRun?.stopRequested == true { + await cancel(runID: run.id, operation: operation, responseID: responseID, recovered: true) + return + } + let response = try await service.retrieveResponse(id: responseID) + guard owns(run.id, operation) else { return } + guard response.id == responseID else { throw ChatIssue.uncertainResponse } + await applyRecovery(response, runID: run.id, operation: operation) + } catch { + guard owns(run.id, operation) else { return } + // In particular, an exact-item 404 does not establish rejection. + lastIssue = .uncertainResponse + snapshot.pendingRun?.phase = .recovering; changed(); scheduleAutosave() + } + } + + /// The account owner revokes the persistence lease separately. In-memory + /// invalidation comes first and rejects even same-account late callbacks. + public func invalidate() { + valid = false; operationID = hooks.uuid() + itemPollID = nil; newestSeenItemID = nil + onDeletionFenceChanged = nil + onRunStarted = nil + for owner in Set(snapshot.ownedDrafts.map(\.id)) { onDraftOwnerRetired?(owner) } + cancelSubmission() + waitingForCapacity = false + runTask?.cancel(); cancelTask?.cancel(); autosaveTask?.cancel() + deletionRecoveryTask?.cancel(); deletionRecoveryTask = nil + detachmentTask?.cancel(); detachmentTask = nil; detachmentID = nil + detachmentSource = nil; detachmentDestination = nil; pendingRekeyFrom = nil + runTask = nil; cancelTask = nil; autosaveTask = nil + messages = []; runOutputIDs.removeAll(); snapshot = ChatSnapshot(draft: ChatDraft(id: hooks.uuid())) + lastIssue = .signedOut; lastOutcome = nil; isLoading = false + paginationID = nil; isLoadingOlder = false + busyDraftIDs.removeAll() + pendingAcceptances.removeAll() + } + + public func saveDraft() async { + guard valid else { return } + autosaveTask?.cancel() + do { try await persistCurrent() } catch { if valid { lastIssue = .storageUnavailable } } + } + + func waitForCurrentWork() async { + let admission = submissionTask, running = runTask + await admission?.value + if admission != nil { await runTask?.value } else { await running?.value } + await cancelTask?.value + } + + private func execute(runID: UUID, operation: UUID) async { + defer { if owns(runID, operation) || owns(operation) { runTask = nil } } + do { + do { try await persistCurrent() } catch { + await restoreRejected(runID: runID, operation: operation, issue: .storageUnavailable); return + } + guard owns(runID, operation) else { return } + if snapshot.pendingRun?.stopRequested == true { + await restoreRejected(runID: runID, operation: operation, issue: nil); return + } + if conversationID == nil { + let project = snapshot.pendingRun?.options.projectID + let creationID = try generateSecureSecret() + snapshot.pendingRun?.creationID = creationID + snapshot.pendingRun?.creationDispatched = true; changed() + do { try await persistCurrent() } catch { + guard owns(runID, operation) else { return } + snapshot.pendingRun?.creationDispatched = false + throw error + } + guard owns(runID, operation) else { return } + if snapshot.pendingRun?.stopRequested == true { + snapshot.pendingRun?.creationDispatched = false + await restoreRejected(runID: runID, operation: operation, issue: nil); return + } + creationInFlight = runID + let conversation: Conversation + do { + conversation = try await service.createConversation( + metadata: ["internal_native_creation_id": .string(creationID)], projectId: project, pinned: nil) + } catch { if creationInFlight == runID { creationInFlight = nil }; throw error } + if creationInFlight == runID { creationInFlight = nil } + guard owns(runID, operation) else { return } + try await adoptConversation(conversation.id, runID: runID, operation: operation) + } + guard let conversationID, let run = snapshot.pendingRun else { return } + if run.stopRequested { + await restoreRejected(runID: runID, operation: operation, issue: nil); return + } + let input = await Self.responseInput(for: run.outgoingDraft) + guard owns(runID, operation) else { return } + if case let .messages(inputMessages) = input, + let index = messages.firstIndex(where: { $0.id == run.messageID }), + let userMessage = inputMessages.first + { + messages[index].content = userMessage.content.map { + switch $0 { + case let .text(text): MessageContent(type: "input_text", text: text) + case let .image(url, detail): + MessageContent(type: "input_image", imageUrl: .string(url), detail: detail) + } + } + } + snapshot.pendingRun?.phase = .sending + // Persist the exact UUID and conservative potentially-dispatched + // marker before entering the SDK, including the crash-before-created gap. + snapshot.pendingRun?.dispatched = true; changed() + do { try await persistCurrent() } catch { + guard owns(runID, operation) else { return } + snapshot.pendingRun?.dispatched = false + await restoreRejected(runID: runID, operation: operation, issue: .storageUnavailable); return + } + guard owns(runID, operation) else { return } + if snapshot.pendingRun?.stopRequested == true { + snapshot.pendingRun?.dispatched = false + await restoreRejected(runID: runID, operation: operation, issue: nil); return + } + let source = try await createResponse( + ResponsesCreateRequest( + model: run.options.model, input: input, conversation: conversationID, + metadata: ["internal_message_id": .string(run.messageID)], webSearch: run.options.webSearch, + reasoningEffort: run.options.reasoningEffort), + runID: runID, operation: operation) + guard owns(runID, operation) else { return } + for try await batch in ChatStreamBatcher.batches(from: source, sleep: hooks.sleep) { + guard owns(runID, operation) else { return } + try await consume(batch, runID: runID, operation: operation) + } + if owns(runID, operation) { + snapshot.pendingRun?.phase = .recovering; changed(); lastIssue = .uncertainResponse + snapshot.queue?.paused = true + try? await persistCurrent() + } + } catch { + guard owns(runID, operation) else { return } + if conversationID == nil, snapshot.pendingRun?.creationDispatched == true, !Self.definitelyRejected(error) { + snapshot.pendingRun?.phase = .recovering; changed(); lastIssue = .uncertainResponse + snapshot.queue?.paused = true; try? await persistCurrent() + } else if snapshot.pendingRun?.dispatched != true || Self.definitelyRejected(error) { + await restoreRejected(runID: runID, operation: operation, issue: Self.issue(error)) + } else { + snapshot.pendingRun?.phase = .recovering; changed(); lastIssue = .uncertainResponse + snapshot.queue?.paused = true + try? await persistCurrent() + } + } + } + + private func createResponse( + _ request: ResponsesCreateRequest, runID: UUID, operation: UUID + ) async throws -> AsyncThrowingStream { + var remaining = 2 + while true { + do { return try await service.createResponse(request, maximumInferenceSends: remaining) } catch { + guard let capacity = findOpenSecretInferenceCapacityError(error), + (1...remaining).contains(capacity.inferenceSendCount) + else { throw error } + remaining -= capacity.inferenceSendCount + guard remaining > 0, let delay = capacity.retryDelayMilliseconds, + owns(runID, operation), snapshot.pendingRun?.stopRequested != true + else { throw error } + // Only the authenticated replay-safe capacity contract can enter + // this path. A shared budget includes the SDK transport resend. + snapshot.pendingRun?.dispatched = false; snapshot.pendingRun?.phase = .preparing; changed() + try await persistCurrent() + guard owns(runID, operation) else { throw CancellationError() } + waitingForCapacity = true + do { try await hooks.sleep(.milliseconds(delay)) } catch { + if owns(runID, operation) { waitingForCapacity = false } + throw error + } + if owns(runID, operation) { waitingForCapacity = false } + guard owns(runID, operation), snapshot.pendingRun?.stopRequested != true, isOnline else { + throw CancellationError() + } + snapshot.pendingRun?.dispatched = true; snapshot.pendingRun?.phase = .sending; changed() + do { try await persistCurrent() } catch { + if owns(runID, operation) { snapshot.pendingRun?.dispatched = false } + throw error + } + guard owns(runID, operation), snapshot.pendingRun?.stopRequested != true else { + if owns(runID, operation) { snapshot.pendingRun?.dispatched = false } + throw CancellationError() + } + } + } + } + + private func consume(_ batch: [ResponseStreamEvent], runID: UUID, operation: UUID) async throws { + var next = messages + for event in batch { + guard owns(runID, operation) else { return } + switch event { + case let .outputItemAdded(value), let .outputItemDone(value): + runOutputIDs[runID, default: []].insert(value.item.id) + case let .contentPartAdded(value), let .contentPartDone(value): + runOutputIDs[runID, default: []].insert(value.itemId) + case let .outputTextDelta(value), let .outputTextDone(value), let .reasoningTextDelta(value), + let .reasoningTextDone(value): + runOutputIDs[runID, default: []].insert(value.itemId) + case let .toolCallCreated(value): + runOutputIDs[runID, default: []].insert(value.toolCallId) + case let .toolOutputCreated(value): + runOutputIDs[runID, default: []].insert(value.toolOutputId) + case let .webSearchInProgress(value), let .webSearchSearching(value), let .webSearchCompleted(value): + runOutputIDs[runID, default: []].insert(value.itemId) + default: break + } + switch event { + case let .created(value), let .inProgress(value): + if let existing = snapshot.pendingRun?.responseID, existing != value.response.id { + throw ChatIssue.uncertainResponse + } + snapshot.pendingRun?.responseID = value.response.id + let nextPhase: ChatRunPhase = snapshot.pendingRun?.stopRequested == true ? .stopping : .streaming + snapshot.pendingRun?.phase = nextPhase + messages = next + changed(); try await persistCurrent() + guard owns(runID, operation) else { return } + // Pagination may have prepended history during protected I/O. + // Continue from its current value rather than a pre-await copy. + next = messages + if snapshot.pendingRun?.stopRequested == true { startCancellation(runID: runID, operation: operation) } + case let .outputItemAdded(value), let .outputItemDone(value): + Self.merge( + value.item, into: &next, authoritative: false, responseID: snapshot.pendingRun?.responseID) + if case .outputItemDone = event { newestSeenItemID = value.item.id } + case let .contentPartAdded(value), let .contentPartDone(value): + try Self.part(value, into: &next) + case let .outputTextDelta(value): try Self.text(value, reasoning: false, final: false, into: &next) + case let .outputTextDone(value): try Self.text(value, reasoning: false, final: true, into: &next) + case let .reasoningTextDelta(value): try Self.text(value, reasoning: true, final: false, into: &next) + case let .reasoningTextDone(value): try Self.text(value, reasoning: true, final: true, into: &next) + case let .toolCallCreated(value): + let status = next.first { $0.id == value.toolCallId }?.status ?? "in_progress" + Self.merge( + ConversationItem( + id: value.toolCallId, type: "function_call", status: status, callId: value.toolCallId, + name: value.name, arguments: value.arguments), into: &next, authoritative: false) + case let .toolOutputCreated(value): + Self.merge( + ConversationItem( + id: value.toolOutputId, type: "function_call_output", status: "completed", + callId: value.toolCallId, + output: .string(value.output)), into: &next, authoritative: false) + if let index = next.firstIndex(where: { $0.id == value.toolCallId && $0.type == "function_call" }) { + next[index].status = "completed" + } + case let .webSearchInProgress(value), let .webSearchSearching(value): + Self.merge( + ConversationItem(id: value.itemId, type: "web_search_call", status: "in_progress"), + into: &next, authoritative: false) + case let .webSearchCompleted(value): + Self.merge( + ConversationItem(id: value.itemId, type: "web_search_call", status: "completed"), + into: &next, authoritative: false) + case let .completed(value), let .failed(value): + if let known = snapshot.pendingRun?.responseID, known != value.response.id { + throw ChatIssue.uncertainResponse + } + guard let outcome = Self.outcome(value.response.status) else { throw ChatIssue.uncertainResponse } + for item in value.response.output ?? [] { + Self.merge(item, into: &next, authoritative: true, responseID: value.response.id) + } + messages = next + await settle( + runID: runID, operation: operation, + outcome: outcome, recovered: false, response: value.response) + return + case let .cancelled(value): + let returnedID = value.response?.id ?? value.id ?? value.data?.id + if let known = snapshot.pendingRun?.responseID, let returnedID, known != returnedID { + throw ChatIssue.uncertainResponse + } + let outcome: ChatOutcome + if let response = value.response { + guard let terminal = Self.outcome(response.status) else { throw ChatIssue.uncertainResponse } + outcome = terminal + } else { + outcome = .cancelled + } + messages = next + await settle( + runID: runID, operation: operation, outcome: outcome, recovered: false, response: value.response); + return + case .error: + messages = next + await settle(runID: runID, operation: operation, outcome: .failed, recovered: false); return + case .unknown: break + } + } + if owns(runID, operation) { messages = next } + } + + private func startCancellation(runID: UUID, operation: UUID) { + guard cancelTask == nil, let responseID = snapshot.pendingRun?.responseID else { return } + cancelTask = Task { + await cancel(runID: runID, operation: operation, responseID: responseID, recovered: false) + if owns(operation) { cancelTask = nil } + } + } + + private func cancel(runID: UUID, operation: UUID, responseID: String, recovered: Bool) async { + do { + let response: ResearchResponse + do { response = try await service.cancelResponse(id: responseID) } catch ResearchAPIError.http( + status: 400, reason: _) + { + response = try await service.retrieveResponse(id: responseID) + } + guard owns(runID, operation) else { return } + guard response.id == responseID, Self.outcome(response.status) != nil else { + throw ChatIssue.cancellationUnavailable + } + await applyRecovery(response, runID: runID, operation: operation, recovered: recovered) + } catch { + guard owns(runID, operation) else { return } + // A generic cancellation failure does not certify quiescence, even + // if a separately fetched database row would look terminal. + lastIssue = .cancellationUnavailable; snapshot.pendingRun?.phase = .recovering + changed(); try? await persistCurrent() + } + } + + private func applyRecovery(_ response: ResearchResponse, runID: UUID, operation: UUID, recovered: Bool = true) async + { + guard owns(runID, operation), let chatID = conversationID else { return } + let outcome: ChatOutcome? = + response.status == .failed && (recovered || snapshot.pendingRun?.stopRequested == true) + ? .cancelled : Self.outcome(response.status) + for item in response.output ?? [] { + Self.merge(item, into: &messages, authoritative: outcome != nil, responseID: response.id) + } + let priorHistoryIssueID = lastIssue == .historyUnavailable ? lastIssueID : nil + do { + let page = try await service.listConversationItems( + conversationId: chatID, parameters: ListParameters(limit: 100, order: .desc)) + guard owns(runID, operation) else { return } + mergeHistory(page.data.reversed(), authoritative: outcome != nil) + newestSeenItemID = page.data.first { $0.status != "in_progress" }?.id ?? newestSeenItemID + if let priorHistoryIssueID, lastIssue == .historyUnavailable, lastIssueID == priorHistoryIssueID { + lastIssue = nil + } + } catch { if owns(runID, operation) { lastIssue = .historyUnavailable } } + guard owns(runID, operation) else { return } + if let outcome { + await settle(runID: runID, operation: operation, outcome: outcome, recovered: recovered, response: response) + } else { + snapshot.pendingRun?.phase = .recovering; changed(); try? await persistCurrent() + } + } + + private func settle( + runID: UUID, operation: UUID, outcome: ChatOutcome, recovered: Bool, response: ResearchResponse? = nil + ) async { + guard owns(runID, operation), let finishedRun = snapshot.pendingRun else { return } + let chatID = conversationID + let wasStopped = finishedRun.stopRequested + // Sparse terminal responses can omit output already received as text deltas. + // Associate only this run's output; older history and explicit links retain their owners. + let responseID = response?.id ?? finishedRun.responseID + let outputIDs = (runOutputIDs.removeValue(forKey: runID) ?? []) + .union(response?.output?.map(\.id) ?? []) + .union(messages.filter { responseID != nil && $0.responseId == responseID && $0.role != "user" }.map(\.id)) + for index in messages.indices where outputIDs.contains(messages[index].id) { + if messages[index].responseId == nil { messages[index].responseId = responseID } + if outcome == .cancelled, ["in_progress", "streaming", "searching"].contains(messages[index].status ?? "") { + messages[index].status = "incomplete" + } + } + let completion = chatID.map { id in + ChatCompletion( + scope: scope, runID: runID, conversationID: id, messageID: finishedRun.messageID, + responseID: responseID, + assistantItems: messages.filter { $0.role == "assistant" && outputIDs.contains($0.id) }, + recovered: recovered, wasStopped: wasStopped, wasVisible: isVisible, wasForeground: isForeground) + } + snapshot.pendingRun = nil; changed(); lastOutcome = outcome + let settlementIssue: ChatIssue? + if outcome == .completed, wasStopped { + settlementIssue = .responseFinishedBeforeStop + } else if recovered { + settlementIssue = outcome == .completed ? .recoveredCompletion : .recoveredStop + } else if outcome == .failed, !wasStopped { + settlementIssue = .generationFailed + } else { + settlementIssue = nil + } + // Settlement owns transient run feedback. History, storage and current + // composer/queue failures remain visible until their own recovery. + if lastIssue == nil + || [ + ChatIssue.uncertainResponse, .cancellationUnavailable, .generationFailed, + .stoppedBeforeSending, .stoppingWhenResponseReady, .responseFinishedBeforeStop, + .recoveredCompletion, .recoveredStop, + ].contains(where: { $0 == lastIssue }) + { + lastIssue = settlementIssue + } + runTask?.cancel(); cancelTask?.cancel() + runTask = nil; cancelTask = nil + if outcome != .completed || recovered || wasStopped { snapshot.queue?.paused = true } + do { try await persistCurrent() } catch { + if owns(operation) { lastIssue = .storageUnavailable; snapshot.queue?.paused = true; changed() } + return + } + guard owns(operation) else { return } + if outcome == .completed, let chatID { + if let completion { onCompletion?(completion) } + onSuccessfulCompletion?(chatID) + if !recovered, !wasStopped, completion?.wasVisible == true, completion?.wasForeground == true, + isVisible, isForeground + { + onSuccessfulVisibleCompletion?(chatID) + } + } + promoteIfEligible() + } + + private func restoreRejected(runID: UUID, operation: UUID, issue: ChatIssue?) async { + guard owns(runID, operation), let run = snapshot.pendingRun else { return } + runOutputIDs.removeValue(forKey: runID) + if let queued = run.queuedTurn { + var queue = snapshot.queue ?? ChatQueueState() + if !queue.items.contains(where: { $0.id == queued.id }) { + queue.items.insert(queued, at: 0) + if queue.edit != nil { queue.edit?.originalIndex += 1 } + if queue.items.count > 16 { queue.recoveredHeadID = queued.id } + } + queue.paused = true; snapshot.queue = queue + } else if snapshot.draft.isEmpty && !isEditingQueue && !busyDraftIDs.contains(snapshot.draft.id) + && submissionDraftID != snapshot.draft.id + { + onDraftOwnerChanging?(snapshot.draft.id) + snapshot.draft = run.outgoingDraft + } else { + snapshot.unsentTurn = run.outgoingDraft + } + messages.removeAll { $0.id == run.messageID } + snapshot.pendingRun = nil; changed(); lastOutcome = run.stopRequested ? .cancelled : .rejected; + snapshot.queue?.paused = true + lastIssue = run.stopRequested && (issue == nil || issue == .requestRejected) ? .stoppedBeforeSending : issue + do { try await persistCurrent() } catch { if owns(operation) { lastIssue = .storageUnavailable } } + } + + private func persistCurrent() async throws { + guard valid else { throw ChatIssue.signedOut } + try await ensureDetached() + guard valid else { throw ChatIssue.signedOut } + let value = snapshot + if let from = pendingRekeyFrom { + try await writes.rekey(from: from, to: value) + if valid, pendingRekeyFrom == from { pendingRekeyFrom = nil } + } else { + try await writes.save(value) + } + publishAcceptances(persisted: value) + } + + private func recordAcceptance(draftID: UUID, messageID: String) { + pendingAcceptances.removeAll { !Self.containsAcceptance($0, in: snapshot) } + pendingAcceptances.append( + ChatSendAcceptance( + scope: scope, storageOwnerID: snapshot.storageOwner, draftID: draftID, + messageID: messageID, conversationID: conversationID)) + } + + private func publishAcceptances(persisted: ChatSnapshot) { + guard valid else { return } + let ready = pendingAcceptances.filter { + Self.containsAcceptance($0, in: persisted) && Self.containsAcceptance($0, in: snapshot) + } + pendingAcceptances.removeAll { ready.contains($0) || !Self.containsAcceptance($0, in: snapshot) } + for value in ready { + guard valid, Self.containsAcceptance(value, in: snapshot) else { continue } + onTurnAccepted?( + ChatSendAcceptance( + scope: scope, storageOwnerID: value.storageOwnerID, draftID: value.draftID, + messageID: value.messageID, conversationID: conversationID)) + } + } + + private static func containsAcceptance(_ value: ChatSendAcceptance, in snapshot: ChatSnapshot) -> Bool { + if let run = snapshot.pendingRun, + run.messageID == value.messageID, run.outgoingDraft.id == value.draftID, !run.stopRequested + { + return true + } + return snapshot.queue?.items.contains { $0.messageID == value.messageID && $0.draft.id == value.draftID } + ?? false + } + + public var isDeletionQuiescent: Bool { + valid && !isLoading && !hasUnresolvedRun && creationInFlight == nil && deletionRecoveryTask == nil + } + + public func beginDeletionFence(_ id: UUID) { + guard valid else { return } + guard deletionFenceIDs.insert(id).inserted else { return } + itemPollID = nil + // Discovery fences admission without interrupting unrelated inference. + // The coordinator requests Stop only after the exact affected set is known. + onDeletionFenceChanged?() + } + + public func releaseDeletionFence(_ id: UUID) { + let changed = deletionFenceIDs.remove(id) != nil + quiescingFenceIDs.remove(id) + // Quiescence calls Stop, so affected queues remain explicitly paused. + // A merely fenced unrelated queue may continue after discovery narrows. + promoteIfEligible() + if changed { onDeletionFenceChanged?() } + } + + /// Starts one owned, nonblocking recovery attempt. A deletion coordinator + /// polls boundedly; it never waits indefinitely for an uncooperative request. + public func requestDeletionQuiescence(_ fenceID: UUID) { + guard valid, deletionFenceIDs.contains(fenceID) else { return } + if quiescingFenceIDs.insert(fenceID).inserted { onQuiesceForDeletion?(); stop() } + guard !isLoading, creationInFlight == nil, deletionRecoveryTask == nil, snapshot.pendingRun != nil else { + return + } + if snapshot.pendingRun?.stopRequested != true { + snapshot.pendingRun?.stopRequested = true; snapshot.pendingRun?.phase = .stopping; changed() + } + guard cancelTask == nil else { return } + deletionRecoveryTask = Task { [weak self] in + guard let self else { return } + await reconcile() + deletionRecoveryTask = nil + } + } + + /// Call only after the server has confirmed deletion of this conversation. + /// The serialized removal runs behind all previously admitted writes. + public func clearAfterDeletion(_ fenceID: UUID) async throws { + if let deletedStorageKey { try await writes.remove(deletedStorageKey); return } + guard deletionFenceIDs.contains(fenceID), isDeletionQuiescent else { throw ChatIssue.cancellationUnavailable } + let key = snapshot.storageKey + deletedStorageKey = key + invalidate() + try await writes.remove(key) + } + + private func adoptConversation(_ id: String, runID: UUID, operation: UUID) async throws { + guard owns(runID, operation), !id.isEmpty, id.utf8.count <= 256 else { throw ChatIssue.uncertainResponse } + autosaveTask?.cancel() + let oldKey = snapshot.storageKey + snapshot.conversationID = id; snapshot.storageKey = id; changed() + pendingRekeyFrom = oldKey + try await persistCurrent() + guard owns(runID, operation) else { return } + onConversationAssigned?(id) + } + + private func findCreatedConversation(_ creationID: String, projectID: String?) async throws -> Conversation? { + guard creationID.utf8.count == 64, + creationID.utf8.allSatisfy({ (48...57).contains($0) || (97...102).contains($0) }) + else { + throw ChatIssue.uncertainResponse + } + var after: String?, seenCursors = Set(), match: Conversation? + for _ in 0..<100 { + try Task.checkCancellation() + let page = try await service.listConversations( + ConversationsListParameters(page: ListParameters(limit: 100, after: after, order: .desc))) + guard page.data.count <= 100 else { throw ChatIssue.uncertainResponse } + for conversation in page.data + where conversation.metadata?["internal_native_creation_id"] == .string(creationID) { + guard conversation.projectId == projectID, match == nil || match?.id == conversation.id else { + throw ChatIssue.uncertainResponse + } + match = conversation + } + if !page.hasMore { return match } + guard let cursor = page.lastId, !page.data.isEmpty, seenCursors.insert(cursor).inserted else { + throw ChatIssue.uncertainResponse + } + after = cursor + } + throw ChatIssue.uncertainResponse + } + + private func detachNewChatIfNeeded(runID: UUID) { + guard snapshot.storageKey == ChatSnapshot.newChatKey else { return } + detachmentSource = snapshot + snapshot.storageKey = "pending:" + runID.uuidString.lowercased(); changed() + detachmentDestination = snapshot + } + + private func ensureDetached() async throws { + guard let source = detachmentSource, let destination = detachmentDestination else { return } + let task: Task + let identifier: UUID + if let existing = detachmentTask, let existingID = detachmentID { + task = existing; identifier = existingID + } else { + identifier = hooks.uuid(); detachmentID = identifier + task = Task { try await writes.detach(source: source, destination: destination) } + detachmentTask = task + } + do { + try await task.value + guard valid else { throw ChatIssue.signedOut } + if detachmentID == identifier { + detachmentSource = nil; detachmentDestination = nil; detachmentTask = nil; detachmentID = nil + } + } catch { + if detachmentID == identifier { detachmentTask = nil; detachmentID = nil } + throw error + } + } + + private func changed() { snapshot.revision &+= 1 } + private func owns(_ operation: UUID) -> Bool { valid && operationID == operation } + private func owns(_ run: UUID, _ operation: UUID) -> Bool { owns(operation) && snapshot.pendingRun?.id == run } + + private func scheduleAutosave() { + autosaveTask?.cancel() + autosaveTask = Task { + do { + try await hooks.sleep(.milliseconds(250)); try Task.checkCancellation() + guard valid else { return } + try await persistCurrent() + } catch is CancellationError {} catch { if valid { lastIssue = .storageUnavailable } } + } + } + + private func mergeHistory(_ items: S, authoritative: Bool) where S.Element == ConversationItem { + for item in items { Self.merge(item, into: &messages, authoritative: authoritative) } + } + + static func outcome(_ status: ResponseStatus) -> ChatOutcome? { + switch status { + case .completed: .completed + case .cancelled, .incomplete: .cancelled + case .failed: .failed + case .queued, .inProgress: nil + } + } + + private static func definitelyRejected(_ error: any Error) -> Bool { + if findOpenSecretInferenceCapacityError(error) != nil { return true } + guard case let ResearchAPIError.http(status, reason) = error else { return false } + return (400..<500).contains(status) && status != 408 || status == 503 && reason == .imageDescriptionUnavailable + } + + private static func issue(_ error: any Error) -> ChatIssue { + if findOpenSecretInferenceCapacityError(error) != nil { return .inferenceCapacityUnavailable } + guard case let ResearchAPIError.http(_, reason) = error else { + return error is ChatIssue ? (error as? ChatIssue ?? .requestRejected) : .requestRejected + } + return switch reason { + case .contextLimit: .contextLimit + case .freeTierTokenLimit: .freeTierTokenLimit + case .usageLimit: .usageLimit + case .modelUnavailable: .modelUnavailable + case .unsupportedValue: .unsupportedValue + case .imageDescriptionUnavailable: .imageDescriptionUnavailable + default: .requestRejected + } + } + + private static func validImages(_ images: [ChatImageAttachment]) -> Bool { + images.count <= 10 && Set(images.map(\.id)).count == images.count + && images.allSatisfy { + !$0.data.isEmpty && $0.data.count <= 20 * 1_024 * 1_024 + && ["image/jpeg", "image/png", "image/webp"].contains($0.contentType) + } + } + + private enum BodyAdmission { case fits, rejected, reserved } + + private func checkBody( + _ draft: ChatDraft, options: ChatSendOptions, isCurrent: @escaping @MainActor () -> Bool, + pauseQueueOnFailure: Bool = false, + onValid: @escaping @MainActor () -> Void + ) -> BodyAdmission { + switch ChatRequestBudget.assess(draft, options: options, conversationID: conversationID) { + case .fits: return .fits + case .exceeds: lastIssue = .draftTooLarge; return .rejected + case .needsEncoding: + guard submissionID == nil else { lastIssue = .busy; return .rejected } + let identifier = hooks.uuid(), conversationID = conversationID + submissionID = identifier; submissionDraftID = draft.id + submissionTask = Task { + do { + let fits = try await ChatRequestBudget.exactFits( + draft, options: options, conversationID: conversationID) + guard valid, submissionID == identifier else { return } + submissionID = nil; submissionDraftID = nil; submissionTask = nil + guard isCurrent() else { return } + if fits { onValid() } else { lastIssue = .draftTooLarge; if pauseQueueOnFailure { pauseQueue() } } + } catch { + guard valid, submissionID == identifier else { return } + submissionID = nil; submissionDraftID = nil; submissionTask = nil + guard isCurrent() else { return } + lastIssue = error is CancellationError ? nil : .draftTooLarge + if pauseQueueOnFailure { pauseQueue() } + } + } + return .reserved + } + } + + private func cancelSubmission() { + submissionID = nil; submissionDraftID = nil; submissionTask?.cancel(); submissionTask = nil + } + + private static func userItem(_ run: ChatPendingRun) -> ConversationItem { + var content: [MessageContent] = [] + if !run.outgoingDraft.text.isEmpty { + content.append(MessageContent(type: "input_text", text: run.outgoingDraft.text)) + } + for image in run.outgoingDraft.images { + // UI resolves local image IDs from the owned draft without base64 work + // on the main actor or a network load from untrusted Markdown. + content.append(MessageContent(type: "input_image", id: image.id.uuidString)) + } + return ConversationItem(id: run.messageID, type: "message", role: "user", content: content) + } + + private nonisolated static func responseInput(for draft: ChatDraft) async -> ResponseInput { + await Task.detached { + var content: [ResponseInputContent] = [] + if !draft.text.isEmpty { content.append(.text(draft.text)) } + content += draft.images.map { + .image(url: "data:\($0.contentType);base64," + $0.data.base64EncodedString()) + } + return .messages([ResponseInputMessage(content: content)]) + }.value + } + + private static func merge( + _ item: ConversationItem, into items: inout [ConversationItem], authoritative: Bool, responseID: String? = nil + ) { + var replacement = item + guard let index = items.firstIndex(where: { $0.id == item.id }) else { + replacement.responseId = replacement.responseId ?? responseID + items.append(replacement); return + } + let old = items[index] + if !authoritative, !compatiblePayloadOwners(old, replacement) { + replacement.responseId = replacement.responseId ?? responseID + items[index] = replacement; return + } + if let oldParts = old.content { + if replacement.content?.isEmpty != false { + replacement.content = oldParts + } else if !authoritative { + if contentLength(oldParts, reasoning: old.type == "reasoning") + >= contentLength(replacement.content ?? [], reasoning: replacement.type == "reasoning") + { + replacement.content = oldParts + } + } + } + if !authoritative { + replacement.arguments = richerPayload(replacement.arguments, retained: old.arguments) + replacement.output = richerPayload(replacement.output, retained: old.output) + replacement.callId = replacement.callId ?? old.callId + replacement.name = replacement.name ?? old.name + if !["completed", "incomplete", "error", "failed", "cancelled"].contains(replacement.status ?? "") { + replacement.status = old.status ?? replacement.status + } + } + replacement.responseId = replacement.responseId ?? old.responseId ?? responseID + replacement.role = replacement.role ?? old.role + items[index] = replacement + } + + private static func contentLength(_ content: [MessageContent], reasoning: Bool) -> Int { + content.reduce(0) { count, part in + if reasoning || ["input_text", "output_text", "text"].contains(part.type) { + return count + (part.text?.utf16.count ?? 0) + } + return count + (part.type == "input_image" ? 1 : 0) + } + } + + private static func compatiblePayloadOwners(_ old: ConversationItem, _ incoming: ConversationItem) -> Bool { + let calls = ["function_call", "tool_call"], outputs = ["function_call_output", "tool_output"] + guard + old.type == incoming.type || (calls.contains(old.type) && calls.contains(incoming.type)) + || (outputs.contains(old.type) && outputs.contains(incoming.type)) + else { return false } + if let oldID = old.callId, let newID = incoming.callId, oldID != newID { return false } + if let oldID = old.responseId, let newID = incoming.responseId, oldID != newID { return false } + if let oldName = old.name, let newName = incoming.name, oldName != newName { return false } + if let oldRole = old.role, let newRole = incoming.role, oldRole != newRole { return false } + return true + } + + private static func richerPayload(_ incoming: JSONValue?, retained: JSONValue?) -> JSONValue? { + guard let retained else { return incoming } + guard let incoming else { return retained } + return payloadLength(retained) >= payloadLength(incoming) ? retained : incoming + } + + private static func payloadLength(_ value: JSONValue) -> Int { + if case let .string(text) = value { return text.utf16.count } + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + guard let data = try? encoder.encode(value), let text = String(data: data, encoding: .utf8) else { return 0 } + return text.utf16.count + } + + private static func part(_ value: ResponseContentEvent, into items: inout [ConversationItem]) throws { + guard (0..<128).contains(value.contentIndex) else { throw ChatIssue.responseUnavailable } + if !items.contains(where: { $0.id == value.itemId }) { + items.append(ConversationItem(id: value.itemId, type: "message", role: "assistant", content: [])) + } + let index = items.firstIndex { $0.id == value.itemId }! + var parts = items[index].content ?? [] + while parts.count <= value.contentIndex { parts.append(MessageContent(type: "output_text", text: "")) } + if (value.part.text?.count ?? 0) >= (parts[value.contentIndex].text?.count ?? 0) { + parts[value.contentIndex] = value.part + } + items[index].content = parts + } + + private static func text( + _ value: ResponseTextEvent, reasoning: Bool, final: Bool, into items: inout [ConversationItem] + ) throws { + guard (0..<128).contains(value.contentIndex) else { throw ChatIssue.responseUnavailable } + if !items.contains(where: { $0.id == value.itemId }) { + items.append( + ConversationItem( + id: value.itemId, type: reasoning ? "reasoning" : "message", + role: reasoning ? nil : "assistant", content: [])) + } + let index = items.firstIndex { $0.id == value.itemId }! + var parts = items[index].content ?? [] + while parts.count <= value.contentIndex { + parts.append(MessageContent(type: reasoning ? "text" : "output_text", text: "")) + } + if final, let text = value.text { + parts[value.contentIndex].text = text + } else { + parts[value.contentIndex].text = (parts[value.contentIndex].text ?? "") + (value.delta ?? "") + } + items[index].content = parts + if items[index].status != "incomplete" { + items[index].status = final ? "completed" : "in_progress" + } + } +} + +/// Queue storage operations in invocation order, even when the underlying actor +/// suspends for protected-data availability or disk I/O. The store still owns +/// revision checks and retirement across separate runtimes/account sessions. +private actor ChatWriteLane { + let persistence: any ChatPersistence + let scope: AccountScope + var tail: Task? + init(persistence: any ChatPersistence, scope: AccountScope) { self.persistence = persistence; self.scope = scope } + func save(_ snapshot: ChatSnapshot) async throws { + let prior = tail + let next = Task { [persistence, scope] in + _ = await prior?.result + try await persistence.save(snapshot, scope: scope) + } + tail = next; try await next.value + } + func remove(_ key: String) async throws { + let prior = tail + let next = Task { [persistence, scope] in + _ = await prior?.result + try await persistence.remove(chatID: key, scope: scope) + } + tail = next; try await next.value + } + func rekey(from: String, to snapshot: ChatSnapshot) async throws { + let prior = tail + let next = Task { [persistence, scope] in + _ = await prior?.result + try await persistence.rekey(from: from, to: snapshot, scope: scope) + } + tail = next; try await next.value + } + func detach(source: ChatSnapshot, destination: ChatSnapshot) async throws { + let prior = tail + let next = Task { [persistence, scope] in + _ = await prior?.result + // A disk write can commit before reporting an error. Never recreate + // the old shared slot when an earlier move already committed. + if try await persistence.load(chatID: destination.storageKey, scope: scope) == nil { + try await persistence.save(source, scope: scope) + } + try await persistence.rekey(from: source.storageKey, to: destination, scope: scope) + } + tail = next; try await next.value + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Chat/ChatStreamBatcher.swift b/apps/maple-ios/Sources/MapleAppCore/Chat/ChatStreamBatcher.swift new file mode 100644 index 000000000..98ead781a --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Chat/ChatStreamBatcher.swift @@ -0,0 +1,81 @@ +import Foundation +import MapleSDK + +/// Consumes SDK events away from the main actor. Text deltas are published in +/// bounded batches; structural and terminal events first flush preceding text. +enum ChatStreamBatcher { + static func batches( + from source: AsyncThrowingStream, + sleep: @escaping @Sendable (Duration) async throws -> Void + ) -> AsyncThrowingStream<[ResponseStreamEvent], Error> { + AsyncThrowingStream(bufferingPolicy: .bufferingOldest(32)) { continuation in + let buffer = Buffer(continuation: continuation, sleep: sleep) + let task = Task.detached { + do { + for try await event in source { + try Task.checkCancellation() + try await buffer.append(event) + } + await buffer.finish() + } catch { await buffer.finish(error: error) } + } + continuation.onTermination = { @Sendable _ in + task.cancel() + Task { await buffer.close() } + } + } + } + + private actor Buffer { + let continuation: AsyncThrowingStream<[ResponseStreamEvent], Error>.Continuation + let sleep: @Sendable (Duration) async throws -> Void + var pending: [ResponseStreamEvent] = [] + var bytes = 0 + var timer: Task? + var closed = false + + init( + continuation: AsyncThrowingStream<[ResponseStreamEvent], Error>.Continuation, + sleep: @escaping @Sendable (Duration) async throws -> Void + ) { + self.continuation = continuation; self.sleep = sleep + } + + func append(_ event: ResponseStreamEvent) throws { + guard !closed else { throw CancellationError() } + switch event { + case let .outputTextDelta(value), let .reasoningTextDelta(value): + pending.append(event); bytes += value.delta?.utf8.count ?? 0 + if pending.count >= 128 || bytes >= 64 * 1024 { try flush(); return } + if timer == nil { + timer = Task { + do { try await sleep(.milliseconds(40)); try flush() } catch is CancellationError {} catch { + finish(error: error) + } + } + } + default: + try flush() + if case .dropped = continuation.yield([event]) { throw ChatIssue.uncertainResponse } + } + } + + func flush() throws { + timer?.cancel(); timer = nil + guard !pending.isEmpty, !closed else { return } + let batch = pending; pending.removeAll(keepingCapacity: true); bytes = 0 + if case .dropped = continuation.yield(batch) { throw ChatIssue.uncertainResponse } + } + + func finish(error: (any Error)? = nil) { + guard !closed else { return } + do { try flush() } catch { continuation.finish(throwing: error); close(); return } + if let error { continuation.finish(throwing: error) } else { continuation.finish() } + close() + } + + func close() { + closed = true; timer?.cancel(); timer = nil; pending.removeAll(); bytes = 0 + } + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Chat/ConversationItemPolling.swift b/apps/maple-ios/Sources/MapleAppCore/Chat/ConversationItemPolling.swift new file mode 100644 index 000000000..d8b45abae --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Chat/ConversationItemPolling.swift @@ -0,0 +1,23 @@ +import Foundation + +/// The account session owns visibility and task cancellation. Check its lease +/// after each delay so an old conversation cannot issue another refresh. +public enum ConversationItemPolling { + @MainActor public static func run( + shouldContinue: @MainActor () -> Bool, + poll: @MainActor () async -> Void, + sleep: @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) } + ) async { + let intervals: [Duration] = [ + .seconds(2), .seconds(5), .seconds(10), .seconds(15), .seconds(20), + .seconds(30), .seconds(60), + ] + var index = 0 + while !Task.isCancelled, shouldContinue() { + do { try await sleep(intervals[index]) } catch { return } + guard !Task.isCancelled, shouldContinue() else { return } + await poll() + index = min(index + 1, intervals.count - 1) + } + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Chat/NewChatProjectSelection.swift b/apps/maple-ios/Sources/MapleAppCore/Chat/NewChatProjectSelection.swift new file mode 100644 index 000000000..6c056d5f4 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Chat/NewChatProjectSelection.swift @@ -0,0 +1,37 @@ +import Foundation + +/// A New Chat project belongs to the selection that requested it, even when +/// loading suspends across another navigation or an account replacement. +@MainActor public enum NewChatProjectSelection { + public struct Current { + public let accountScope: AccountScope? + public let navigation: UUID + public let selectedKey: String + public let runtime: ChatRuntime? + + public init(accountScope: AccountScope?, navigation: UUID, selectedKey: String, runtime: ChatRuntime?) { + self.accountScope = accountScope + self.navigation = navigation + self.selectedKey = selectedKey + self.runtime = runtime + } + } + + public static func perform( + scope: AccountScope, projectID: String?, + navigate: @MainActor (UUID) async -> Bool, + current: @MainActor () -> Current, + assign: @MainActor (ChatRuntime, String?) -> Void + ) async { + let ticket = UUID() + guard await navigate(ticket), !Task.isCancelled else { return } + let selected = current() + guard selected.accountScope == scope, selected.navigation == ticket, + selected.selectedKey == ChatSnapshot.newChatKey, + let runtime = selected.runtime, runtime.scope == scope, !runtime.isRetired, + runtime.conversationID == nil, runtime.snapshot.storageKey == ChatSnapshot.newChatKey + else { return } + // No suspension between admission and mutation of this exact runtime. + assign(runtime, projectID) + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Dictation/DictationContracts.swift b/apps/maple-ios/Sources/MapleAppCore/Dictation/DictationContracts.swift new file mode 100644 index 000000000..cc05e1eb2 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Dictation/DictationContracts.swift @@ -0,0 +1,102 @@ +import Foundation +import MapleSDK + +public enum DictationIssue: String, Error, Sendable { + case permissionDenied, captureUnavailable, emptyRecording, recordingTooLarge + case transcriptionUnavailable, emptyTranscription, invalidTranscription, tooManyOperations, draftUnavailable +} + +public enum DictationPhase: String, Sendable { + case requestingPermission, recording, transcribing, waitingForComposition, waitingForInsertionPoint, failed +} + +public struct DictationStatus: Sendable, Equatable { + public let draftID: UUID + public var phase: DictationPhase + public var elapsedSeconds: Int + public var issue: DictationIssue? +} + +/// A view must obtain all ranges from this exact text. A stale editor snapshot +/// cannot move the marker for a newer draft revision. +public struct DictationEditorState: Sendable, Equatable { + public var text: String + public var selectionUTF16: NSRange + public var markedRangeUTF16: NSRange? + + public init(text: String, selectionUTF16: NSRange, markedRangeUTF16: NSRange? = nil) { + self.text = text; self.selectionUTF16 = selectionUTF16; self.markedRangeUTF16 = markedRangeUTF16 + } +} + +/// The actual text-input transaction, needed to distinguish identical-looking +/// edits at different positions (for example deleting one of repeated letters). +public struct DictationTextEdit: Sendable, Equatable { + public var beforeText: String + public var rangeUTF16: NSRange + public var replacement: String + public init(beforeText: String, rangeUTF16: NSRange, replacement: String) { + self.beforeText = beforeText; self.rangeUTF16 = rangeUTF16; self.replacement = replacement + } + public var resultingText: String? { + guard rangeUTF16.location != NSNotFound, rangeUTF16.location >= 0, rangeUTF16.length >= 0, + rangeUTF16.location <= beforeText.utf16.count, + rangeUTF16.length <= beforeText.utf16.count - rangeUTF16.location, + let range = Range(rangeUTF16, in: beforeText) + else { return nil } + return beforeText.replacingCharacters(in: range, with: replacement) + } +} + +/// The adapter owns one microphone capture. Stop synchronously detaches its +/// buffers before returning; only in-memory little-endian 16 kHz mono PCM16 +/// crosses this boundary. Every operation checks its unique capture ID. +@MainActor public protocol DictationCapturing: AnyObject { + func requestPermission() async -> Bool + func start(id: UUID, maximumFrames: Int, onLimit: @escaping @MainActor @Sendable () -> Void) throws + func stop(id: UUID) throws -> Data + func cancel(id: UUID) +} + +public protocol DictationTranscribing: Sendable { + func transcribe(wav: Data) async throws -> String +} + +public struct SDKDictationService: DictationTranscribing { + private let api: ResearchAPI + public init(api: ResearchAPI) { self.api = api } + public func transcribe(wav: Data) async throws -> String { + try await api.transcribeAudio( + data: wav, filename: "dictation.wav", contentType: "audio/wav", model: "whisper-large-v3" + ).text + } +} + +public enum DictationAudio { + public static let sampleRate = 16_000 + public static let maximumSeconds = 300 + public static let maximumFrames = sampleRate * maximumSeconds + public static let maximumPCMBytes = maximumFrames * 2 + + /// Call away from the UI actor. Neither PCM nor its WAV container is saved. + public static func wav(pcm16Mono data: Data) throws -> Data { + guard !data.isEmpty else { throw DictationIssue.emptyRecording } + guard data.count <= maximumPCMBytes, data.count.isMultiple(of: 2) else { + throw DictationIssue.recordingTooLarge + } + var result = Data(); result.reserveCapacity(data.count + 44) + func append(_ value: UInt32) { + var little = value.littleEndian + withUnsafeBytes(of: &little) { result.append(contentsOf: $0) } + } + func short(_ value: UInt16) { + var little = value.littleEndian + withUnsafeBytes(of: &little) { result.append(contentsOf: $0) } + } + result.append(contentsOf: "RIFF".utf8); append(UInt32(data.count + 36)) + result.append(contentsOf: "WAVEfmt ".utf8); append(16); short(1); short(1) + append(UInt32(sampleRate)); append(UInt32(sampleRate * 2)); short(2); short(16) + result.append(contentsOf: "data".utf8); append(UInt32(data.count)); result.append(data) + return result + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Dictation/DictationCoordinator.swift b/apps/maple-ios/Sources/MapleAppCore/Dictation/DictationCoordinator.swift new file mode 100644 index 000000000..366da08be --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Dictation/DictationCoordinator.swift @@ -0,0 +1,347 @@ +import Foundation +import Observation + +public enum DictationActivity: Sendable { case capturing, transcribing, idle } + +@MainActor @Observable public final class DictationCoordinator { + public let scope: AccountScope + public private(set) var statuses: [UUID: DictationStatus] = [:] + public private(set) var lastIssue: DictationIssue? + public private(set) var lastIssueID: UUID? + /// Fires once at admitted permission request, before its playback hold. A + /// denied/cancelled request still owns its later review; navigation cannot + /// redirect a Send from another draft into that review. + public var onCaptureAdmitted: (@MainActor (DictationDraftOwner) -> Void)? + public var onActivityChange: (@MainActor (DictationActivity) -> Void)? + + private let capture: any DictationCapturing + private let transcriber: any DictationTranscribing + private let hooks: ChatRuntimeHooks + private var jobs: [UUID: Job] = [:] + private var editors: [UUID: DictationEditorState] = [:] + private var editorRuntimes: [UUID: ObjectIdentifier] = [:] + private var capturingID: UUID? + private weak var issueRuntime: ChatRuntime? + private var issueDraftID: UUID? + private var valid = true + + private final class Job { + let id = UUID() + let runtime: ChatRuntime + let draftID: UUID + var anchor: DictationInsertionAnchor? + var wav: Data? + var result: String? + var anchorReliable = true + var task: Task? + var timer: Task? + init(runtime: ChatRuntime, draftID: UUID) { self.runtime = runtime; self.draftID = draftID } + } + + public init( + scope: AccountScope, capture: any DictationCapturing, + transcriber: any DictationTranscribing, hooks: ChatRuntimeHooks = ChatRuntimeHooks() + ) { + self.scope = scope; self.capture = capture; self.transcriber = transcriber; self.hooks = hooks + } + + public func status(for runtime: ChatRuntime, draftID: UUID? = nil) -> DictationStatus? { + let id = draftID ?? runtime.snapshot.draft.id + guard let job = jobs[id], job.runtime === runtime else { return nil } + return statuses[id] + } + + public func issue(for runtime: ChatRuntime) -> DictationIssue? { + issueID(for: runtime) == nil ? nil : lastIssue + } + + public func issueID(for runtime: ChatRuntime) -> UUID? { + guard valid, !runtime.isRetired, runtime.scope == scope, issueRuntime === runtime, + issueDraftID == runtime.snapshot.draft.id, lastIssue != nil + else { return nil } + return lastIssueID + } + + /// The visible controls own this delay. Disappearance cancels their task; + /// an old occurrence must never clear a later error with the same value. + public func expireIssueAfterDelay(id: UUID, runtime: ChatRuntime) async { + guard issueID(for: runtime) == id, !Task.isCancelled else { return } + do { try await hooks.sleep(.seconds(5)) } catch { return } + guard issueID(for: runtime) == id, !Task.isCancelled else { return } + dismissIssue() + } + + public func dismissIssue() { + lastIssue = nil; lastIssueID = nil; issueRuntime = nil; issueDraftID = nil + } + + public func editorState(runtime: ChatRuntime, draftID: UUID) -> DictationEditorState? { + guard valid, runtime.scope == scope, editorRuntimes[draftID] == ObjectIdentifier(runtime), + let state = editors[draftID], runtime.ownedDraft(id: draftID)?.text == state.text + else { return nil } + return state + } + + /// Called after a text edit or selection/composition change, using the exact + /// owner that produced it. A held draft has no current selection changes. + public func updateEditor( + runtime: ChatRuntime, draftID: UUID, state: DictationEditorState, edit: DictationTextEdit? = nil + ) { + guard valid, runtime.scope == scope, runtime.ownedDraft(id: draftID)?.text == state.text, + Self.validRange(state.selectionUTF16, in: state.text), + state.markedRangeUTF16.map({ Self.validRange($0, in: state.text) }) ?? true + else { return } + if let job = jobs[draftID], job.runtime !== runtime { return } + editors[draftID] = state; editorRuntimes[draftID] = ObjectIdentifier(runtime) + guard let job = jobs[draftID], job.runtime === runtime else { return } + if let edit, edit.resultingText == state.text, job.anchor?.text == edit.beforeText { + job.anchor?.rebase(using: edit) + } else { + job.anchor?.rebase(to: state.text) + } + if job.result != nil, state.markedRangeUTF16 == nil { insertResult(job) } + } + + /// Interactive editors supply every actual replacement in order. Invalid or + /// missing transactions never guess among repeated text: a completed result + /// is retained for the user's explicit insertion-point choice instead. + public func updateEditor( + runtime: ChatRuntime, draftID: UUID, state: DictationEditorState, edits: [DictationTextEdit] + ) { + guard valid, runtime.scope == scope, runtime.ownedDraft(id: draftID)?.text == state.text, + Self.validRange(state.selectionUTF16, in: state.text), + state.markedRangeUTF16.map({ Self.validRange($0, in: state.text) }) ?? true + else { return } + if let job = jobs[draftID], job.runtime !== runtime { return } + editors[draftID] = state; editorRuntimes[draftID] = ObjectIdentifier(runtime) + guard let job = jobs[draftID], job.runtime === runtime, var anchor = job.anchor else { return } + var matches = job.anchorReliable + for edit in edits { + guard matches else { break } + matches = anchor.rebase(using: edit) + } + matches = matches && anchor.text == state.text + if matches { job.anchor = anchor } else { job.anchorReliable = false } + if job.result != nil { insertResult(job) } + } + + public func insertAtCurrentPosition(runtime: ChatRuntime) { + let owner = runtime.snapshot.draft.id + guard let job = jobs[owner], job.runtime === runtime, owns(job), job.result != nil, + let draft = runtime.ownedDraft(id: owner), let editor = editors[owner], editor.text == draft.text + else { return } + job.anchor = DictationInsertionAnchor( + text: draft.text, offsetUTF16: editor.selectionUTF16.location + editor.selectionUTF16.length) + job.anchorReliable = true + insertResult(job) + } + + @discardableResult public func start(runtime: ChatRuntime) -> Bool { + let owner = runtime.snapshot.draft.id + guard valid, !runtime.isRetired, !runtime.isLoading, runtime.deletionFenceIDs.isEmpty, runtime.scope == scope, + runtime.ownedDraft(id: owner) != nil, + !runtime.busyDraftIDs.contains(owner), jobs[owner] == nil, capturingID == nil + else { setIssue(.draftUnavailable, runtime: runtime, owner: owner); return false } + guard jobs.count < 4 else { setIssue(.tooManyOperations, runtime: runtime, owner: owner); return false } + let job = Job(runtime: runtime, draftID: owner) + jobs[owner] = job; capturingID = job.id; runtime.busyDraftIDs.insert(owner) + statuses[owner] = DictationStatus(draftID: owner, phase: .requestingPermission, elapsedSeconds: 0) + onCaptureAdmitted?( + DictationDraftOwner(scope: scope, storageOwnerID: runtime.snapshot.storageOwner, draftID: owner)) + dismissIssue(); publishActivity() + job.task = Task { [self] in + let granted = await capture.requestPermission() + guard owns(job), !Task.isCancelled else { remove(job); return } + guard granted else { fail(job, issue: .permissionDenied, retainingClip: false); return } + do { + try capture.start(id: job.id, maximumFrames: DictationAudio.maximumFrames) { [weak self] in + guard let self, self.owns(job) else { return } + self.stop(runtime: job.runtime, draftID: job.draftID) + } + guard owns(job), !Task.isCancelled else { capture.cancel(id: job.id); return } + statuses[owner]?.phase = .recording; job.task = nil + startTimer(job) + } catch { fail(job, issue: .captureUnavailable, retainingClip: false) } + } + return true + } + + /// This method stops the actual capture before returning, allowing callers + /// to change visible composer ownership safely in the same actor turn. + public func stop(runtime: ChatRuntime, draftID: UUID? = nil) { + let owner = draftID ?? runtime.snapshot.draft.id + guard let job = jobs[owner], job.runtime === runtime, owns(job) else { return } + guard statuses[owner]?.phase != .requestingPermission else { cancel(runtime: runtime, draftID: owner); return } + guard statuses[owner]?.phase == .recording else { return } + job.timer?.cancel(); job.timer = nil + guard let draft = runtime.ownedDraft(id: owner) else { remove(job); return } + let editor = editors[owner].flatMap { $0.text == draft.text ? $0 : nil } + let offset = editor.map { $0.selectionUTF16.location + $0.selectionUTF16.length } ?? draft.text.utf16.count + job.anchor = DictationInsertionAnchor(text: draft.text, offsetUTF16: offset) + do { + let pcm = try capture.stop(id: job.id) + capturingID = nil; statuses[owner]?.phase = .transcribing; publishActivity() + let encoding = Task.detached { try DictationAudio.wav(pcm16Mono: pcm) } + job.task = Task { [self] in + do { + let wav = try await encoding.value + guard owns(job), !Task.isCancelled else { remove(job); return } + job.wav = wav + await transcribe(job) + } catch { + if owns(job) { + fail(job, issue: error as? DictationIssue ?? .captureUnavailable, retainingClip: false) + } + } + } + } catch { fail(job, issue: .captureUnavailable, retainingClip: false) } + } + + public func stopCaptureForNavigation() { + for job in Array(jobs.values) where job.id == capturingID { + stop(runtime: job.runtime, draftID: job.draftID) + } + } + + public func ownerWillChange(runtime: ChatRuntime, draftID: UUID) { + stop(runtime: runtime, draftID: draftID) + } + + public func ownerRetired(runtime: ChatRuntime, draftID: UUID) { + cancel(runtime: runtime, draftID: draftID) + if editorRuntimes[draftID] == ObjectIdentifier(runtime) { + editors[draftID] = nil; editorRuntimes[draftID] = nil + } + } + + public func retry(runtime: ChatRuntime, draftID: UUID? = nil) { + let owner = draftID ?? runtime.snapshot.draft.id + guard let job = jobs[owner], job.runtime === runtime, owns(job), + statuses[owner]?.phase == .failed, job.wav != nil + else { return } + statuses[owner]?.phase = .transcribing; statuses[owner]?.issue = nil + if issueRuntime === runtime && issueDraftID == owner { dismissIssue() } + publishActivity() + job.task = Task { [self] in await transcribe(job) } + } + + public func cancel(runtime: ChatRuntime, draftID: UUID? = nil) { + let owner = draftID ?? runtime.snapshot.draft.id + guard let job = jobs[owner], job.runtime === runtime else { return } + remove(job) + } + + public func invalidate() { + guard valid else { return } + valid = false + for job in Array(jobs.values) { remove(job) } + editors.removeAll(); editorRuntimes.removeAll(); dismissIssue() + } + + private func transcribe(_ job: Job) async { + guard owns(job), let wav = job.wav else { return } + do { + let text = try await transcriber.transcribe(wav: wav) + guard owns(job), !Task.isCancelled else { remove(job); return } + guard text.utf8.count <= 256 * 1024 else { + fail(job, issue: .invalidTranscription, retainingClip: true); return + } + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + fail(job, issue: .emptyTranscription, retainingClip: false); return + } + job.result = text; job.wav = nil; job.task = nil + insertResult(job) + } catch { + guard owns(job), !Task.isCancelled else { return } + fail(job, issue: .transcriptionUnavailable, retainingClip: true) + } + } + + private func insertResult(_ job: Job) { + guard owns(job), let result = job.result, var draft = job.runtime.ownedDraft(id: job.draftID) else { return } + guard job.anchorReliable else { + statuses[job.draftID]?.phase = .waitingForInsertionPoint; publishActivity(); return + } + job.anchor?.rebase(to: draft.text) + if editors[job.draftID]?.markedRangeUTF16 != nil { + statuses[job.draftID]?.phase = .waitingForComposition; publishActivity(); return + } + guard let anchor = job.anchor else { remove(job); return } + let before = draft.text + draft.text = anchor.inserting(result) + guard job.runtime.updateOwnedDraft(draft) else { remove(job); return } + if var editor = editors[job.draftID], editor.text == before { + let added = draft.text.utf16.count - before.utf16.count + let start = editor.selectionUTF16.location + let end = start + editor.selectionUTF16.length + let shiftedStart = start >= anchor.offsetUTF16 ? start + added : start + let shiftedEnd = end >= anchor.offsetUTF16 ? end + added : end + editor.text = draft.text + editor.selectionUTF16 = NSRange(location: shiftedStart, length: shiftedEnd - shiftedStart) + editor.markedRangeUTF16 = nil + editors[job.draftID] = editor + } + // updateOwnedDraft schedules the same protected, owner-fenced autosave as + // typing; protection failures leave the inserted text visibly unsaved. + remove(job) + } + + private func owns(_ job: Job) -> Bool { + valid && jobs[job.draftID] === job && !job.runtime.isRetired && job.runtime.scope == scope + && job.runtime.ownedDraft(id: job.draftID) != nil + } + + private func fail(_ job: Job, issue: DictationIssue, retainingClip: Bool) { + guard owns(job) else { return } + setIssue(issue, runtime: job.runtime, owner: job.draftID) + if retainingClip { + statuses[job.draftID]?.phase = .failed; statuses[job.draftID]?.issue = issue + job.task = nil; publishActivity() + } else { + remove(job) + } + } + + private func remove(_ job: Job) { + guard jobs[job.draftID] === job else { return } + // Retire the identity before cancelling work that may complete late. + jobs[job.draftID] = nil; statuses[job.draftID] = nil + if capturingID == job.id { capturingID = nil } + job.task?.cancel(); job.timer?.cancel(); capture.cancel(id: job.id) + job.task = nil; job.timer = nil; job.wav = nil; job.result = nil + job.runtime.busyDraftIDs.remove(job.draftID) + publishActivity() + } + + private func setIssue(_ issue: DictationIssue, runtime: ChatRuntime, owner: UUID) { + issueRuntime = runtime; issueDraftID = owner; lastIssue = issue; lastIssueID = hooks.uuid() + } + + private func startTimer(_ job: Job) { + job.timer = Task { [self] in + do { + for elapsed in 1...DictationAudio.maximumSeconds { + try await hooks.sleep(.seconds(1)) + guard owns(job), !Task.isCancelled, statuses[job.draftID]?.phase == .recording else { return } + statuses[job.draftID]?.elapsedSeconds = elapsed + if elapsed == DictationAudio.maximumSeconds { stop(runtime: job.runtime, draftID: job.draftID) } + } + } catch {} + } + } + + private func publishActivity() { + if statuses.values.contains(where: { $0.phase == .recording || $0.phase == .requestingPermission }) { + onActivityChange?(.capturing) + } else if statuses.values.contains(where: { $0.phase == .transcribing || $0.phase == .waitingForComposition }) { + onActivityChange?(.transcribing) + } else { + onActivityChange?(.idle) + } + } + + private static func validRange(_ range: NSRange, in text: String) -> Bool { + range.location != NSNotFound && range.location >= 0 && range.length >= 0 + && range.location <= text.utf16.count && range.length <= text.utf16.count - range.location + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Dictation/DictationInsertionAnchor.swift b/apps/maple-ios/Sources/MapleAppCore/Dictation/DictationInsertionAnchor.swift new file mode 100644 index 000000000..60d5fda5c --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Dictation/DictationInsertionAnchor.swift @@ -0,0 +1,75 @@ +import Foundation + +/// A right-affinity logical insertion point. Its text snapshot is memory-only; +/// caret movement after Stop never changes this position. Edits are compared at +/// extended grapheme boundaries, so emoji and combining marks are not split. +public struct DictationInsertionAnchor: Sendable, Equatable { + public private(set) var text: String + public private(set) var offsetUTF16: Int + + public init(text: String, offsetUTF16: Int) { + self.text = text; self.offsetUTF16 = Self.boundary(in: text, atOrBefore: offsetUTF16) + } + + public mutating func rebase(to replacement: String) { + guard text != replacement else { return } + let old = Array(text); let new = Array(replacement) + var prefix = 0 + while prefix < min(old.count, new.count), old[prefix] == new[prefix] { prefix += 1 } + var suffix = 0 + while suffix < min(old.count - prefix, new.count - prefix), + old[old.count - suffix - 1] == new[new.count - suffix - 1] + { suffix += 1 } + let start = old.prefix(prefix).reduce(0) { $0 + String($1).utf16.count } + let oldEnd = old.prefix(old.count - suffix).reduce(0) { $0 + String($1).utf16.count } + let newEnd = new.prefix(new.count - suffix).reduce(0) { $0 + String($1).utf16.count } + if offsetUTF16 >= oldEnd { + offsetUTF16 += newEnd - oldEnd + } else if offsetUTF16 >= start { + offsetUTF16 = newEnd + } + text = replacement + offsetUTF16 = Self.boundary(in: replacement, atOrBefore: offsetUTF16) + } + + @discardableResult public mutating func rebase(using edit: DictationTextEdit) -> Bool { + guard edit.beforeText == text, let replacement = edit.resultingText else { return false } + let start = edit.rangeUTF16.location + let oldEnd = start + edit.rangeUTF16.length + let newEnd = start + edit.replacement.utf16.count + if offsetUTF16 >= oldEnd { + offsetUTF16 += newEnd - oldEnd + } else if offsetUTF16 >= start { + offsetUTF16 = newEnd + } + text = replacement + offsetUTF16 = Self.boundary(in: replacement, atOrBefore: offsetUTF16) + return true + } + + public func inserting(_ transcript: String) -> String { + let index = String.Index(utf16Offset: offsetUTF16, in: text) + let before = String(text[.. Bool { + guard let left, let right, !left.isWhitespace, !right.isWhitespace else { return false } + return !",.;:!?)]}、。!?".contains(right) && !"([{“‘".contains(left) + } + + private static func boundary(in text: String, atOrBefore requested: Int) -> Int { + let limit = max(0, min(requested, text.utf16.count)) + var position = 0 + for character in text { + let next = position + String(character).utf16.count + if next > limit { break } + position = next + } + return position + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Images/ImageImportController.swift b/apps/maple-ios/Sources/MapleAppCore/Images/ImageImportController.swift new file mode 100644 index 000000000..1c9d92246 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Images/ImageImportController.swift @@ -0,0 +1,173 @@ +import Foundation +import Observation + +public struct SelectedImageSource: Sendable { + public let read: @Sendable () async throws -> Data + public init(read: @escaping @Sendable () async throws -> Data) { self.read = read } +} + +@MainActor public final class ImageImportBudget { + private var tickets: [UUID: Int] = [:] + private let maximumTemporaryBytes: Int + public init(maximumTemporaryBytes: Int = 256 * 1_024 * 1_024) { + self.maximumTemporaryBytes = min(256 * 1_024 * 1_024, max(0, maximumTemporaryBytes)) + } + fileprivate func acquire() -> UUID? { + guard tickets.count < 4 else { return nil } + let ticket = UUID(); tickets[ticket] = 0; return ticket + } + fileprivate func release(_ ticket: UUID) { tickets.removeValue(forKey: ticket) } + fileprivate func reserve(_ bytes: Int, ticket: UUID) throws { + guard bytes >= 0, tickets[ticket] != nil else { throw ImageImportIssue.retired } + let others = tickets.filter { $0.key != ticket }.values.reduce(0, +) + guard bytes <= maximumTemporaryBytes - others else { throw ImageImportIssue.attachmentLimit } + tickets[ticket] = bytes + } +} + +/// A picker result belongs to the draft which opened it. Importing into held B +/// is allowed; importing into a sent/retired draft or a replacement account is not. +@MainActor @Observable public final class ImageImportController { + public let runtime: ChatRuntime + public private(set) var issue: ImageImportIssue? { didSet { scheduleIssueExpiry() } } + public private(set) var importingDrafts: Set = [] + @ObservationIgnored private let processor: any ImageProcessing + @ObservationIgnored private let budget: ImageImportBudget + @ObservationIgnored private let admission: @MainActor () -> Bool + @ObservationIgnored private let capacityIssue: @MainActor (ChatSnapshot) -> ImageImportIssue? + @ObservationIgnored private let sleep: @Sendable (Duration) async throws -> Void + @ObservationIgnored private(set) var issueExpiry: Task? + @ObservationIgnored private var issueOccurrence = UUID() + @ObservationIgnored private var jobs: [UUID: Task] = [:] + @ObservationIgnored private var tickets: [UUID: UUID] = [:] + private var valid = true + + public init( + runtime: ChatRuntime, processor: any ImageProcessing, budget: ImageImportBudget = ImageImportBudget(), + capacityIssue: @escaping @MainActor (ChatSnapshot) -> ImageImportIssue? = { _ in nil }, + sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) }, + admission: @escaping @MainActor () -> Bool = { true } + ) { + self.runtime = runtime; self.processor = processor; self.budget = budget; self.sleep = sleep + self.admission = admission; self.capacityIssue = capacityIssue + } + + private func scheduleIssueExpiry() { + issueExpiry?.cancel(); issueExpiry = nil + issueOccurrence = UUID() + guard valid, issue != nil else { return } + let occurrence = issueOccurrence, sleep = sleep + issueExpiry = Task { [weak self] in + do { try await sleep(.seconds(5)) } catch { return } + guard !Task.isCancelled, let self, valid, issueOccurrence == occurrence else { return } + issue = nil + } + } + public func waitForImports() async { + for job in Array(jobs.values) { await job.value } + } + + public func importImages(_ sources: [SelectedImageSource], draftID: UUID) { + guard valid, !runtime.isRetired, !runtime.isLoading, runtime.deletionFenceIDs.isEmpty, + let draft = runtime.ownedDraft(id: draftID), !sources.isEmpty + else { issue = .retired; return } + guard admission() else { issue = .unavailable; return } + guard !runtime.busyDraftIDs.contains(draftID) else { issue = .unavailable; return } + guard sources.count <= 10 - draft.images.count else { issue = .attachmentLimit; return } + guard let ticket = budget.acquire() else { issue = .unavailable; return } + tickets[draftID] = ticket + importingDrafts.insert(draftID); runtime.busyDraftIDs.insert(draftID); issue = nil + let processor = processor, budget = budget + jobs[ticket] = Task { [weak self] in + defer { budget.release(ticket); self?.finish(draftID: draftID, ticket: ticket) } + do { + var imported: [ChatImageAttachment] = [] + for source in sources { + guard self?.admission() == true else { throw ImageImportIssue.unavailable } + let data = try await source.read() + try Task.checkCancellation() + guard self?.admission() == true else { throw ImageImportIssue.unavailable } + let retainedBytes = imported.reduce(0) { $0 + $1.data.count } + try budget.reserve(retainedBytes + data.count, ticket: ticket) + let image = try await processor.process(data) + try Task.checkCancellation() + try budget.reserve(retainedBytes + image.data.count, ticket: ticket) + guard let self, valid, tickets[draftID] == ticket, + let proposed = proposedImages(imported + [image], draftID: draftID) + else { return } + guard admission() else { throw ImageImportIssue.unavailable } + try validateCapacity(proposed) + imported.append(image) + } + guard let self, valid, tickets[draftID] == ticket, + var current = runtime.ownedDraft(id: draftID) + else { return } + guard current.images.count + imported.count <= 10 else { throw ImageImportIssue.attachmentLimit } + current.images += imported + var proposed = runtime.snapshot + if proposed.draft.id == draftID { + proposed.draft = current + } else if proposed.queue?.edit?.heldDraft.id == draftID { + proposed.queue?.edit?.heldDraft = current + } else { + return + } + guard admission() else { throw ImageImportIssue.unavailable } + try validateCapacity(proposed) + _ = runtime.updateOwnedDraft(current) + } catch is CancellationError { return } catch { + guard let self, valid, tickets[draftID] == ticket else { return } + issue = (error as? ImageImportIssue) ?? .unavailable + } + } + } + + private func validateCapacity(_ proposed: ChatSnapshot) throws { + let usage = ChatResourceUsage(snapshots: [proposed]) + if usage.images > 640 { throw ImageImportIssue.accountImageLimit } + if usage.imageBytes > 256 * 1_024 * 1_024 { throw ImageImportIssue.accountAttachmentLimit } + if let issue = capacityIssue(proposed) { throw issue } + guard usage.fitsAccount, runtime.canAdmitSnapshot?(proposed) ?? true else { + throw ImageImportIssue.attachmentLimit + } + } + + private func proposedImages(_ images: [ChatImageAttachment], draftID: UUID) -> ChatSnapshot? { + guard var current = runtime.ownedDraft(id: draftID), !runtime.isRetired else { return nil } + current.images += images + var proposed = runtime.snapshot + if proposed.draft.id == draftID { + proposed.draft = current + } else if proposed.queue?.edit?.heldDraft.id == draftID { + proposed.queue?.edit?.heldDraft = current + } else { + return nil + } + return proposed + } + + public func cancel(draftID: UUID) { + guard let ticket = tickets.removeValue(forKey: draftID) else { return } + jobs[ticket]?.cancel() + importingDrafts.remove(draftID); runtime.busyDraftIDs.remove(draftID) + } + + public func removeImage(_ id: UUID, draftID: UUID) { + guard valid, var draft = runtime.ownedDraft(id: draftID) else { return } + draft.images.removeAll { $0.id == id }; _ = runtime.updateOwnedDraft(draft) + } + + public func report(_ value: ImageImportIssue) { if valid { issue = value } } + public func invalidate() { + valid = false + for owner in Array(tickets.keys) { cancel(draftID: owner) } + issue = nil + } + + private func finish(draftID: UUID, ticket: UUID) { + jobs.removeValue(forKey: ticket) + guard tickets[draftID] == ticket else { return } + tickets.removeValue(forKey: draftID) + importingDrafts.remove(draftID); runtime.busyDraftIDs.remove(draftID) + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Images/ImageImportProcessor.swift b/apps/maple-ios/Sources/MapleAppCore/Images/ImageImportProcessor.swift new file mode 100644 index 000000000..751b77c5a --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Images/ImageImportProcessor.swift @@ -0,0 +1,98 @@ +import CoreGraphics +import Foundation +import ImageIO +import UniformTypeIdentifiers + +public enum ImageImportIssue: Error, Equatable, Sendable { + case unsupported, tooLarge, invalidImage, unavailable, retired, attachmentLimit + case accountImageLimit, accountAttachmentLimit +} + +/// ImageIO decodes one bounded thumbnail at a time off the UI actor. Re-encoding +/// removes source metadata and converts native HEIF captures into a wire format. +public protocol ImageProcessing: Sendable { + func process(_ data: Data) async throws -> ChatImageAttachment +} + +public actor ImageImportProcessor: ImageProcessing { + public static let maximumInputBytes = 20 * 1_024 * 1_024 + public init() {} + + public func process(_ data: Data) throws -> ChatImageAttachment { + try Task.checkCancellation() + guard !data.isEmpty, data.count <= Self.maximumInputBytes else { throw ImageImportIssue.tooLarge } + guard + let source = CGImageSourceCreateWithData( + data as CFData, [kCGImageSourceShouldCache: false] as CFDictionary), + CGImageSourceGetStatus(source) == .statusComplete, + CGImageSourceGetCount(source) == 1, + let type = CGImageSourceGetType(source) as String?, + [ + UTType.png.identifier, UTType.jpeg.identifier, UTType.webP.identifier, + UTType.heic.identifier, UTType.heif.identifier, + ].contains(type), + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let width = properties[kCGImagePropertyPixelWidth] as? NSNumber, + let height = properties[kCGImagePropertyPixelHeight] as? NSNumber + else { throw ImageImportIssue.unsupported } + guard width.doubleValue.isFinite, height.doubleValue.isFinite, + width.doubleValue >= 1, height.doubleValue >= 1, + width.doubleValue <= 100_000, height.doubleValue <= 100_000, + width.doubleValue * height.doubleValue <= 100_000_000 + else { throw ImageImportIssue.tooLarge } + let lossless = type == UTType.png.identifier || type == UTType.webP.identifier + let outputType = lossless ? UTType.png : UTType.jpeg + // Most screenshots retain their native dimensions. Exceptionally large + // encoded results step down conservatively instead of truncating bytes. + for edge in [4_096, 3_072, 2_048] { + try Task.checkCancellation() + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: edge, + kCGImageSourceShouldCacheImmediately: true, + ] + guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { + throw ImageImportIssue.invalidImage + } + let result = NSMutableData() + guard let destination = CGImageDestinationCreateWithData(result, outputType.identifier as CFString, 1, nil) + else { + throw ImageImportIssue.unavailable + } + CGImageDestinationAddImage( + destination, image, + [kCGImageDestinationLossyCompressionQuality: 0.92] as CFDictionary) + guard CGImageDestinationFinalize(destination) else { throw ImageImportIssue.invalidImage } + try Task.checkCancellation() + if result.length <= Self.maximumInputBytes { + return ChatImageAttachment(data: result as Data, contentType: lossless ? "image/png" : "image/jpeg") + } + } + throw ImageImportIssue.tooLarge + } + + /// Read only an explicitly selected provider URL while its grant is held. + /// No user source is changed or removed, and file size is checked again by + /// the bounded read even if the provider's metadata is stale. + public nonisolated static func readSelection(_ url: URL) throws -> Data { + let scoped = url.startAccessingSecurityScopedResource() + defer { if scoped { url.stopAccessingSecurityScopedResource() } } + let attributes = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) + guard attributes.isRegularFile == true else { throw ImageImportIssue.unsupported } + guard let bytes = attributes.fileSize, bytes > 0, bytes <= maximumInputBytes else { + throw ImageImportIssue.tooLarge + } + let file = try FileHandle(forReadingFrom: url) + defer { try? file.close() } + var data = Data() + while data.count <= maximumInputBytes { + try Task.checkCancellation() + let remaining = maximumInputBytes + 1 - data.count + guard let chunk = try file.read(upToCount: min(remaining, 128 * 1_024)), !chunk.isEmpty else { break } + data.append(chunk) + } + guard !data.isEmpty, data.count <= maximumInputBytes else { throw ImageImportIssue.tooLarge } + return data + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Library/ChatDeletionCoordinator.swift b/apps/maple-ios/Sources/MapleAppCore/Library/ChatDeletionCoordinator.swift new file mode 100644 index 000000000..333b28902 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Library/ChatDeletionCoordinator.swift @@ -0,0 +1,229 @@ +import Foundation +import Observation + +public enum ChatDeletionTarget: Sendable, Equatable { + case conversations(Set) + case project(String, knownConversationIDs: Set) + case all +} +public struct ChatDeletionLease: Sendable, Hashable { public let id: UUID } +public enum ChatDeletionError: Error, Sendable { case retired, invalidLease, timedOut, cleanupRequired } +public struct ChatDeletionHooks: Sendable { + public let elapsed: @Sendable () -> Duration + public let sleep: @Sendable (Duration) async throws -> Void + public init( + elapsed: (@Sendable () -> Duration)? = nil, + sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) } + ) { + let clock = ContinuousClock(), start = ContinuousClock.now + self.elapsed = elapsed ?? { start.duration(to: clock.now) }; self.sleep = sleep + } +} + +/// Account-wide admission and cleanup authority. Retained snapshots participate +/// even when their chats have never been opened in this process. +@MainActor @Observable public final class ChatDeletionCoordinator { + public let scope: AccountScope + public private(set) var cleanupRequired = false + @ObservationIgnored private let hooks: ChatDeletionHooks + @ObservationIgnored private var valid = true + @ObservationIgnored private var targets: [UUID: ChatDeletionTarget] = [:] + @ObservationIgnored private var owners: [ObjectIdentifier: ChatRuntime] = [:] + @ObservationIgnored private var shadows: Set = [] + @ObservationIgnored private var projects: [ObjectIdentifier: String] = [:] + @ObservationIgnored private var cleanups: [ObjectIdentifier: (ChatRuntime, UUID)] = [:] + @ObservationIgnored private var cleanupIdentities: + [ObjectIdentifier: (key: String, owner: UUID, conversationID: String?)] = [:] + private var deletedConversationIDs: Set = [] + public func isConversationDeleted(_ id: String) -> Bool { deletedConversationIDs.contains(id) } + public var onRemoved: (@MainActor (ChatRuntime) -> Void)? + public var hasActiveFence: Bool { !targets.isEmpty } + + public init( + scope: AccountScope, retained: [ChatSnapshot], service: any ChatServing, + persistence: any ChatPersistence, hooks: ChatDeletionHooks = ChatDeletionHooks() + ) { + self.scope = scope; self.hooks = hooks + for snapshot in retained { + let runtime = ChatRuntime( + scope: scope, service: service, persistence: persistence, + conversationID: snapshot.conversationID) + if runtime.loadRetainedSnapshot(snapshot) { + let id = ObjectIdentifier(runtime) + owners[id] = runtime; shadows.insert(id) + } + } + for runtime in owners.values { attachAdmission(runtime) } + } + + /// Always use the returned owner. An in-flight destructive lease may already + /// own the shadow restored from this same protected snapshot. + @discardableResult public func register(_ runtime: ChatRuntime, projectID: String? = nil) -> ChatRuntime { + guard valid, runtime.scope == scope else { runtime.invalidate(); return runtime } + let id = ObjectIdentifier(runtime) + if let existing = owners.values.first(where: { + guard $0 !== runtime, let identity = cleanupIdentities[ObjectIdentifier($0)] else { return false } + return identity.key == runtime.snapshot.storageKey || identity.owner == runtime.snapshot.storageOwner + || (identity.conversationID != nil && identity.conversationID == runtime.conversationID) + }) { + runtime.invalidate(); return existing + } + if let conversationID = runtime.conversationID, deletedConversationIDs.contains(conversationID) { + runtime.invalidate(); return runtime + } + if let existing = owners.values.first(where: { + $0 !== runtime + && ($0.snapshot.storageOwner == runtime.snapshot.storageOwner + || $0.snapshot.storageKey == runtime.snapshot.storageKey) + }) { + if !targets.isEmpty { runtime.invalidate(); return existing } + let oldID = ObjectIdentifier(existing) + if projectID == nil { projects[id] = projects[oldID] } + owners.removeValue(forKey: oldID); shadows.remove(oldID); projects.removeValue(forKey: oldID) + existing.invalidate() + } + owners[id] = runtime; shadows.remove(id) + if let projectID { projects[id] = projectID } + attachAdmission(runtime) + for (lease, target) in targets where matches(runtime, target: target) { runtime.beginDeletionFence(lease) } + return runtime + } + + public func updateProject(conversationID: String, projectID: String?) { + for (id, runtime) in owners where runtime.conversationID == conversationID { + projects[id] = projectID + for (lease, target) in targets where matches(runtime, target: target) { runtime.beginDeletionFence(lease) } + } + } + + public func begin(_ target: ChatDeletionTarget) throws -> ChatDeletionLease { + guard valid else { throw ChatDeletionError.retired } + guard !cleanupRequired else { throw ChatDeletionError.cleanupRequired } + let lease = ChatDeletionLease(id: UUID()); targets[lease.id] = target + for runtime in owners.values where matches(runtime, target: target) { runtime.beginDeletionFence(lease.id) } + return lease + } + + /// Narrow a conservative discovery fence only after authoritative project + /// membership is fully enumerated. Matching owners keep their fence without a gap. + public func retarget(_ lease: ChatDeletionLease, to target: ChatDeletionTarget) throws { + try require(lease) + targets[lease.id] = target + for runtime in owners.values { + if matches(runtime, target: target) { + runtime.beginDeletionFence(lease.id) + } else { + runtime.releaseDeletionFence(lease.id) + } + } + } + + public func quiesce(_ lease: ChatDeletionLease, timeout: Duration = .seconds(30)) async throws { + try require(lease) + let deadline = hooks.elapsed() + max(.zero, timeout) + while true { + try Task.checkCancellation(); try require(lease) + let runtimes = matching(lease) + for runtime in runtimes { runtime.requestDeletionQuiescence(lease.id) } + if runtimes.allSatisfy(\.isDeletionQuiescent) { return } + let remaining = deadline - hooks.elapsed() + guard remaining > .zero else { throw ChatDeletionError.timedOut } + try await hooks.sleep(min(.milliseconds(250), remaining)) + } + } + + public func assertQuiescent(_ lease: ChatDeletionLease) throws { + try require(lease) + guard matching(lease).allSatisfy(\.isDeletionQuiescent) else { throw ChatDeletionError.timedOut } + } + + /// Clear only server-confirmed IDs, or all members after an all-history, + /// project, or account deletion succeeds. Failures stay retryable locally. + public func finish(_ lease: ChatDeletionLease, deletedConversationIDs: Set = [], deleteScope: Bool = false) + async throws + { + try require(lease) + self.deletedConversationIDs.formUnion(deletedConversationIDs) + if deleteScope, let target = targets[lease.id], case .project(_, let knownIDs) = target { + self.deletedConversationIDs.formUnion(knownIDs) + } + let removing = matching(lease).filter { + deleteScope || $0.conversationID.map(deletedConversationIDs.contains) == true + } + // Freeze every cleanup identity before the first suspension. Invalidating + // an owner erases its snapshot, but navigation must still resolve to it. + for runtime in removing { + let owner = ObjectIdentifier(runtime) + cleanupIdentities[owner] = ( + runtime.snapshot.storageKey, runtime.snapshot.storageOwner, runtime.conversationID + ) + if let id = runtime.conversationID { self.deletedConversationIDs.insert(id) } + } + for runtime in removing { + let owner = ObjectIdentifier(runtime) + cleanups[owner] = (runtime, lease.id) + do { + try await runtime.clearAfterDeletion(lease.id) + guard valid else { throw ChatDeletionError.retired } + cleanups.removeValue(forKey: owner); cleanupIdentities.removeValue(forKey: owner); + owners.removeValue(forKey: owner); + projects.removeValue(forKey: owner) + shadows.remove(owner); onRemoved?(runtime) + } catch { cleanupRequired = true } + } + if cleanupRequired { throw ChatDeletionError.cleanupRequired } + } + + public func retryCleanup() async throws { + guard valid else { throw ChatDeletionError.retired } + for (owner, entry) in cleanups { + try await entry.0.clearAfterDeletion(entry.1) + guard valid else { throw ChatDeletionError.retired } + cleanups.removeValue(forKey: owner); cleanupIdentities.removeValue(forKey: owner); + owners.removeValue(forKey: owner); projects.removeValue(forKey: owner) + shadows.remove(owner); onRemoved?(entry.0) + } + cleanupRequired = !cleanups.isEmpty + } + + public func release(_ lease: ChatDeletionLease) { + targets.removeValue(forKey: lease.id) + for runtime in owners.values { runtime.releaseDeletionFence(lease.id) } + } + + public func invalidate() { + valid = false + for id in shadows { owners[id]?.invalidate() } + for (lease, _) in targets { for runtime in owners.values { runtime.releaseDeletionFence(lease) } } + targets.removeAll(); owners.removeAll(); cleanups.removeAll(); cleanupIdentities.removeAll(); + deletedConversationIDs.removeAll(); projects.removeAll(); shadows.removeAll() + } + + private func require(_ lease: ChatDeletionLease) throws { + guard valid else { throw ChatDeletionError.retired } + guard targets[lease.id] != nil else { throw ChatDeletionError.invalidLease } + } + private func matching(_ lease: ChatDeletionLease) -> [ChatRuntime] { + guard let target = targets[lease.id] else { return [] } + // A pending project creation can settle and lose its run options before + // deletion. Its already admitted fence remains membership evidence. + return owners.values.filter { $0.deletionFenceIDs.contains(lease.id) || matches($0, target: target) } + } + private func matches(_ runtime: ChatRuntime, target: ChatDeletionTarget, options: ChatSendOptions? = nil) -> Bool { + switch target { + case .all: true + case .conversations(let ids): runtime.conversationID.map(ids.contains) ?? false + case .project(let project, let ids): + runtime.conversationID.map(ids.contains) == true + || projects[ObjectIdentifier(runtime)] == project + || runtime.snapshot.pendingRun?.options.projectID == project + || options?.projectID == project + } + } + private func attachAdmission(_ runtime: ChatRuntime) { + runtime.deletionAdmission = { [weak self, weak runtime] options in + guard let self, let runtime, valid, !cleanupRequired else { return false } + return !targets.values.contains { matches(runtime, target: $0, options: options) } + } + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Library/ConversationActivity.swift b/apps/maple-ios/Sources/MapleAppCore/Library/ConversationActivity.swift new file mode 100644 index 000000000..851a18032 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Library/ConversationActivity.swift @@ -0,0 +1,5 @@ +/// Session-owned status shown by library rows. Server history loading does +/// not create an unread flag; a retained run's successful completion does. +public enum ConversationActivity: Sendable, Equatable { + case idle, running, unread +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Library/InstructionEditor.swift b/apps/maple-ios/Sources/MapleAppCore/Library/InstructionEditor.swift new file mode 100644 index 000000000..49960e6b1 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Library/InstructionEditor.swift @@ -0,0 +1,121 @@ +import Foundation +import MapleSDK +import Observation + +public enum InstructionDestination: Sendable, Equatable { case account, project(String) } + +/// Explicit-save editor. A failed load is not interpreted as an empty default, +/// and navigation can use isDirty to ask whether to discard unsaved edits. +@MainActor @Observable public final class InstructionEditor { + public let scope: AccountScope + public let destination: InstructionDestination + public private(set) var isLoading = false + public private(set) var isSaving = false + public private(set) var isLoaded = false + public private(set) var didSave = false + public private(set) var issue: LibraryIssue? + private var draft = "" + private var original = "" + private var instructionID: String? + @ObservationIgnored private let service: any LibraryServing + @ObservationIgnored private var valid = true + @ObservationIgnored private var operation = UUID() + @ObservationIgnored private var editRevision: UInt64 = 0 + public var text: String { + get { draft } + set { guard valid, isLoaded, !isSaving else { return }; draft = newValue; didSave = false; editRevision &+= 1 } + } + public var isDirty: Bool { isLoaded && draft != original } + + public init(scope: AccountScope, destination: InstructionDestination, service: any LibraryServing) { + self.scope = scope; self.destination = destination; self.service = service + } + public func load() async { + guard valid, !isLoading, !isSaving, !isDirty else { return } + let ticket = UUID(); operation = ticket; isLoading = true; issue = nil; didSave = false + let loadedRevision = editRevision + do { + let text: String + var loadedInstructionID: String? + switch destination { + case .project(let id): + let project = try await service.getProject(id: id) + guard owns(ticket) else { return } + guard project.id == id else { throw LibraryIssue.invalidResponse } + text = project.instructions ?? "" + case .account: + let current = try await loadDefault(ticket) + guard owns(ticket) else { return } + loadedInstructionID = current?.id; text = current?.prompt ?? "" + } + guard owns(ticket) else { return } + guard loadedRevision == editRevision else { isLoading = false; return } + instructionID = loadedInstructionID + draft = text; original = text; isLoaded = true; isLoading = false + } catch { if owns(ticket) { isLoading = false; issue = LibraryIssue.present(error) } } + } + public func save() async { + guard valid, isLoaded, !isLoading, !isSaving else { return } + let ticket = UUID(); operation = ticket; isSaving = true; issue = nil; didSave = false + let submitted = draft + let empty = submitted.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + do { + switch destination { + case .project(let id): + let updated = try await service.updateProject( + id: id, name: nil, instructions: empty ? .clear : .set(submitted)) + guard owns(ticket) else { return } + guard updated.id == id else { throw LibraryIssue.invalidResponse } + case .account: + if let id = instructionID { + if empty { + let deleted = try await service.deleteInstruction(id: id) + guard owns(ticket) else { return } + guard deleted.deleted, deleted.id == nil || deleted.id == id else { + throw LibraryIssue.invalidResponse + } + instructionID = nil + } else { + let updated = try await service.updateInstruction( + id: id, name: nil, prompt: submitted, isDefault: nil) + guard owns(ticket) else { return } + guard updated.id == id else { throw LibraryIssue.invalidResponse } + } + } else if !empty { + let created = try await service.createInstruction( + name: "User Preferences", prompt: submitted, isDefault: true) + guard owns(ticket) else { return } + guard created.isDefault, !created.id.isEmpty else { throw LibraryIssue.invalidResponse } + instructionID = created.id + } + } + guard owns(ticket) else { return } + draft = empty ? "" : submitted; original = draft; isSaving = false; didSave = true + } catch { if owns(ticket) { isSaving = false; issue = LibraryIssue.present(error) } } + } + public func discardChanges() { + guard valid, !isSaving else { return }; draft = original; didSave = false; editRevision &+= 1 + } + public func invalidate() { + valid = false; operation = UUID(); draft = ""; original = ""; instructionID = nil + isLoaded = false; isLoading = false; isSaving = false; didSave = false + } + private func owns(_ ticket: UUID) -> Bool { valid && operation == ticket } + private func loadDefault(_ ticket: UUID) async throws -> Instruction? { + var after: String?, visited = Set(), current: Instruction? + for _ in 0..<100 { + let page = try await service.listInstructions(ListParameters(limit: 100, after: after, order: .desc)) + guard owns(ticket) else { throw LibraryIssue.retired } + for instruction in page.data where instruction.isDefault { + guard current == nil || current?.id == instruction.id else { throw LibraryIssue.invalidResponse } + current = instruction + } + if !page.hasMore { return current } + guard let next = page.lastId, !page.data.isEmpty, visited.insert(next).inserted else { + throw LibraryIssue.invalidResponse + } + after = next + } + throw LibraryIssue.invalidResponse + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Library/LibraryContracts.swift b/apps/maple-ios/Sources/MapleAppCore/Library/LibraryContracts.swift new file mode 100644 index 000000000..42f70e661 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Library/LibraryContracts.swift @@ -0,0 +1,125 @@ +import Foundation +import MapleSDK + +public protocol LibraryServing: Sendable { + func listConversations(_ parameters: ConversationsListParameters) async throws -> APIPage + func getConversation(id: String) async throws -> Conversation + func updateConversation( + id: String, metadata: [String: JSONValue]?, projectId: NullableUpdate, pinned: Bool? + ) async throws -> Conversation + func deleteConversation(id: String) async throws -> DeletedObject + func deleteAllConversations() async throws -> DeletedObject + func batchDeleteConversations(ids: [String]) async throws -> BatchDeleteResponse + func batchUpdateConversationProject(ids: [String], projectId: String?) async throws -> BatchUpdateProjectResponse + func listProjects(_ parameters: ListParameters) async throws -> APIPage + func getProject(id: String) async throws -> ConversationProject + func createProject(name: String) async throws -> ConversationProject + func updateProject(id: String, name: String?, instructions: NullableUpdate) async throws + -> ConversationProject + func deleteProject(id: String) async throws -> DeletedObject + func listInstructions(_ parameters: ListParameters) async throws -> APIPage + func createInstruction(name: String, prompt: String, isDefault: Bool?) async throws -> Instruction + func updateInstruction(id: String, name: String?, prompt: String?, isDefault: Bool?) async throws -> Instruction + func deleteInstruction(id: String) async throws -> DeletedObject +} + +public struct SDKLibraryService: LibraryServing { + private let api: ResearchAPI + public init(api: ResearchAPI) { self.api = api } + public func listConversations(_ parameters: ConversationsListParameters) async throws -> APIPage { + try await api.listConversations(parameters) + } + public func getConversation(id: String) async throws -> Conversation { try await api.getConversation(id: id) } + public func updateConversation( + id: String, metadata: [String: JSONValue]?, projectId: NullableUpdate, pinned: Bool? + ) async throws -> Conversation { + try await api.updateConversation(id: id, metadata: metadata, projectId: projectId, pinned: pinned) + } + public func deleteConversation(id: String) async throws -> DeletedObject { + try await api.deleteConversation(id: id) + } + public func deleteAllConversations() async throws -> DeletedObject { try await api.deleteAllConversations() } + public func batchDeleteConversations(ids: [String]) async throws -> BatchDeleteResponse { + try await api.batchDeleteConversations(ids: ids) + } + public func batchUpdateConversationProject(ids: [String], projectId: String?) async throws + -> BatchUpdateProjectResponse + { try await api.batchUpdateConversationProject(ids: ids, projectId: projectId) } + public func listProjects(_ parameters: ListParameters) async throws -> APIPage { + try await api.listProjects(parameters) + } + public func getProject(id: String) async throws -> ConversationProject { try await api.getProject(id: id) } + public func createProject(name: String) async throws -> ConversationProject { + try await api.createProject(name: name) + } + public func updateProject(id: String, name: String?, instructions: NullableUpdate) async throws + -> ConversationProject + { try await api.updateProject(id: id, name: name, instructions: instructions) } + public func deleteProject(id: String) async throws -> DeletedObject { try await api.deleteProject(id: id) } + public func listInstructions(_ parameters: ListParameters) async throws -> APIPage { + try await api.listInstructions(parameters) + } + public func createInstruction(name: String, prompt: String, isDefault: Bool?) async throws -> Instruction { + try await api.createInstruction(name: name, prompt: prompt, isDefault: isDefault) + } + public func updateInstruction(id: String, name: String?, prompt: String?, isDefault: Bool?) async throws + -> Instruction + { try await api.updateInstruction(id: id, name: name, prompt: prompt, isDefault: isDefault) } + public func deleteInstruction(id: String) async throws -> DeletedObject { try await api.deleteInstruction(id: id) } +} + +public enum LibraryFilter: Sendable, Equatable { case all, pinned, recent, unassigned, project(String) } +/// A menu drop changes project membership and pinning together. Pinning keeps +/// the existing project; the other destinations are mutually exclusive rows. +public enum LibraryConversationDestination: Sendable, Equatable, Hashable { + case project(String), pinned, recent +} + +/// The caller owns the selected chat and account lease. Recheck that ownership +/// after each delay before refreshing a title generated in the background. +public enum ConversationTitlePolling { + @MainActor public static func run( + shouldContinue: @MainActor () -> Bool, + refresh: @MainActor () async -> Void, + sleep: @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) } + ) async { + var delay: Duration = .milliseconds(500) + while !Task.isCancelled, shouldContinue() { + do { try await sleep(delay) } catch { return } + guard !Task.isCancelled, shouldContinue() else { return } + await refresh() + delay = min(delay * 2, .seconds(10)) + } + } +} + +/// The account-owned menu stops polling when it is hidden or backgrounded. +/// An injected sleep keeps lifecycle and stale-result checks deterministic. +public enum LibraryRecentsPolling { + @MainActor public static func run( + shouldContinue: @MainActor () -> Bool, + refresh: @MainActor () async -> Void, + sleep: @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) } + ) async { + while !Task.isCancelled, shouldContinue() { + do { try await sleep(.seconds(60)) } catch { return } + guard !Task.isCancelled, shouldContinue() else { return } + await refresh() + } + } +} + +public enum LibraryIssue: Error, Sendable, Equatable { + case connection, invalidInput, invalidResponse, busy, retired, quiescence, cleanupRequired, partialMutation + static func present(_ error: Error) -> LibraryIssue { + if let issue = error as? LibraryIssue { return issue } + if let deletion = error as? ChatDeletionError { + switch deletion { + case .cleanupRequired: return .cleanupRequired + case .retired: return .retired + default: return .quiescence + } + } + return .connection + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Library/LibraryMenuModel.swift b/apps/maple-ios/Sources/MapleAppCore/Library/LibraryMenuModel.swift new file mode 100644 index 000000000..9d041e19f --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Library/LibraryMenuModel.swift @@ -0,0 +1,167 @@ +import Foundation +import Observation + +/// Account-owned menu snapshots are loaded before the chat scene is published. +/// Recreating a compact or split-view menu never creates a second set of pages. +@MainActor @Observable public final class LibraryMenuModel { + public let root: LibraryModel + public let scope: AccountScope + public let pinned: LibraryModel + public let recent: LibraryModel + public private(set) var projects: [String: LibraryModel] = [:] + public var expandedProjects: Set = [] + public private(set) var isPrepared = false + public private(set) var searchText = "" + public var onModelCreated: (@MainActor (LibraryModel) -> Void)? + @ObservationIgnored private let makeLibrary: @MainActor () -> LibraryModel + @ObservationIgnored private var active = true + @ObservationIgnored private var refreshTask: Task? + @ObservationIgnored private var searchTask: Task? + @ObservationIgnored private var searchActive = false + @ObservationIgnored private var recentsPoll: Task? + @ObservationIgnored private var recentsPollTicket: UUID? + @ObservationIgnored private var recentsRefreshTask: Task? + @ObservationIgnored private var recentsRefreshTicket: UUID? + @ObservationIgnored private var recentsVisible = false + @ObservationIgnored private var recentsForeground = true + + public init(root: LibraryModel, makeLibrary: @escaping @MainActor () -> LibraryModel) { + self.root = root; scope = root.scope; self.makeLibrary = makeLibrary + pinned = makeLibrary(); recent = makeLibrary() + } + + public func project(_ id: String) -> LibraryModel { + if let current = projects[id] { return current } + let model = makeLibrary() + model.searchText = searchText + projects[id] = model; onModelCreated?(model) + if !active { model.invalidate() } + return model + } + + /// Only restored open folders are fetched at launch. Closed folders acquire + /// their first page when opened or when the user searches the menu. + public func prepare(isExpanded: @escaping @MainActor (String) -> Bool) async { + guard active, !isPrepared else { return } + await refresh(isExpanded: isExpanded) + } + + /// One shared refresh keeps the previous complete snapshots visible and + /// prevents overlapping cursor work from independently recreated views. + public func refresh(isExpanded: @escaping @MainActor (String) -> Bool) async { + guard active else { return } + if let refreshTask { await refreshTask.value; return } + let task = Task { @MainActor [weak self] in + guard let self, active else { return } + async let folders: Void = root.loadProjects() + async let pins: Void = pinned.load(.pinned) + async let latest: Void = recent.load(.recent) + _ = await (folders, pins, latest) + await searchTask?.value + guard active, !Task.isCancelled else { return } + let validIDs = Set(root.projects.map(\.id)) + for id in Array(projects.keys) where !validIDs.contains(id) { + projects.removeValue(forKey: id)?.invalidate() + } + expandedProjects.formIntersection(validIDs) + for item in root.projects { + let model = project(item.id) + if model.hasLoadedConversations || isExpanded(item.id) || (searchActive && !searchText.isEmpty) { + await model.load(.project(item.id)) + guard active, !Task.isCancelled else { return } + } + } + isPrepared = true + } + refreshTask = task + await task.value + refreshTask = nil + } + + public func setSearch(_ value: String) { + guard active else { return } + searchText = value; pinned.searchText = value; recent.searchText = value + searchActive = !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + for model in projects.values { model.searchText = value } + let prior = searchTask + prior?.cancel() + searchTask = Task { @MainActor [weak self] in + await prior?.value + guard let self, active else { return } + guard !Task.isCancelled, searchActive else { return } + for item in root.projects { + guard active, !Task.isCancelled else { return } + let model = project(item.id) + if !model.hasLoadedConversations { await model.load(.project(item.id)) } + } + } + } + + /// Mounted recents fetch only their latest page every minute. Loaded older + /// pages and an explicit menu refresh retain their existing owners. + public func updateRecentsPolling( + visible: Bool, foreground: Bool, + sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) } + ) { + let wasPolling = recentsVisible && recentsForeground + recentsVisible = visible; recentsForeground = foreground + guard active, visible, foreground else { + recentsPoll?.cancel(); recentsPoll = nil; recentsPollTicket = nil + if wasPolling || !foreground { cancelRecentsRefresh() } + return + } + guard recentsPoll == nil else { return } + let ticket = UUID(); recentsPollTicket = ticket + recentsPoll = Task { @MainActor [weak self] in + defer { + if self?.recentsPollTicket == ticket { + self?.recentsPoll = nil; self?.recentsPollTicket = nil + } + } + await LibraryRecentsPolling.run( + shouldContinue: { [weak self] in + guard let self else { return false } + return active && recentsVisible && recentsForeground && recentsPollTicket == ticket + }, + refresh: { [weak self] in await self?.refreshRecents() }, sleep: sleep) + } + } + + /// Conversation creation updates recents immediately, including while the + /// menu is hidden. Backgrounding or retirement revokes an in-flight read. + public func refreshRecents() async { + guard active, recentsForeground, !Task.isCancelled else { return } + if let recentsRefreshTask { await recentsRefreshTask.value; return } + let ticket = UUID(); recentsRefreshTicket = ticket + let task = Task { @MainActor [weak self] in + guard let self else { return } + await recent.pollRecentConversations(shouldApply: { [weak self] in + guard let self else { return false } + return active && recentsForeground && recentsRefreshTicket == ticket + }) + } + recentsRefreshTask = task + await task.value + if recentsRefreshTicket == ticket { recentsRefreshTask = nil; recentsRefreshTicket = nil } + } + + private func cancelRecentsRefresh() { + recentsRefreshTask?.cancel(); recentsRefreshTask = nil; recentsRefreshTicket = nil + } + + /// Hiding the menu stops speculative folder reads without losing its + /// query, loaded rows or cursors. The next visible refresh resumes search. + public func cancelSearchLoading() { + searchActive = false; searchTask?.cancel() + } + + public func invalidate() { + active = false; refreshTask?.cancel(); searchTask?.cancel() + recentsPoll?.cancel(); recentsPoll = nil; recentsPollTicket = nil + cancelRecentsRefresh() + refreshTask = nil; searchTask = nil; onModelCreated = nil + pinned.invalidate(); recent.invalidate() + for model in projects.values { model.invalidate() } + projects.removeAll(); expandedProjects.removeAll(); searchText = ""; isPrepared = false; searchActive = false + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Library/LibraryModel.swift b/apps/maple-ios/Sources/MapleAppCore/Library/LibraryModel.swift new file mode 100644 index 000000000..38987549a --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Library/LibraryModel.swift @@ -0,0 +1,463 @@ +import Foundation +import MapleSDK +import Observation + +@MainActor @Observable public final class LibraryModel { + public let scope: AccountScope + public private(set) var conversations: [Conversation] = [] + public private(set) var projects: [ConversationProjectListItem] = [] + public private(set) var filter: LibraryFilter = .all + public private(set) var isLoading = false + public private(set) var isLoadingProjects = false + public private(set) var isMutating = false + public private(set) var hasMore = false + public private(set) var issue: LibraryIssue? + public var searchText = "" + public var selectedIDs: Set = [] + public var onConversationUpdated: (@MainActor (Conversation) -> Void)? + public var onConversationProjectUpdated: (@MainActor (Set, String?) -> Void)? + public var onConversationsDeleted: (@MainActor (Set, Bool) -> Void)? + public var onProjectDeleted: (@MainActor (String) -> Void)? + public var onProjectUpdated: (@MainActor (ConversationProject) -> Void)? + @ObservationIgnored private let service: any LibraryServing + @ObservationIgnored private let deletions: ChatDeletionCoordinator + @ObservationIgnored private var valid = true + @ObservationIgnored private var loadID = UUID() + @ObservationIgnored private var projectLoadID = UUID() + @ObservationIgnored private var revision: UInt64 = 0 + @ObservationIgnored private var cursor: String? + @ObservationIgnored private var cursors: Set = [] + private var loadedPages = 0 + + public init(scope: AccountScope, service: any LibraryServing, deletions: ChatDeletionCoordinator) { + self.scope = scope; self.service = service; self.deletions = deletions + } + public var visibleConversations: [Conversation] { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + return conversations.filter { + accepts($0) && (query.isEmpty || Self.title($0).localizedCaseInsensitiveContains(query)) + } + } + public var visibleProjects: [ConversationProjectListItem] { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + return projects.filter { project in + query.isEmpty || project.name.localizedCaseInsensitiveContains(query) + || conversations.contains { + $0.projectId == project.id && Self.title($0).localizedCaseInsensitiveContains(query) + } + } + } + public static func title(_ conversation: Conversation) -> String { + if case .string(let title) = conversation.metadata?["title"], + !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return title + } + return "" + } + + public var hasLoadedConversations: Bool { loadedPages > 0 } + public var isActive: Bool { valid } + + /// The menu observes the actual trailing row's visibility. Search remains + /// local to loaded titles and never drains every page as a side effect. + public func loadMoreIfNeeded(visibleID: String) async { + guard searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + visibleConversations.suffix(3).contains(where: { $0.id == visibleID }), issue == nil + else { return } + await loadMore() + } + + public func load(_ filter: LibraryFilter = .all) async { + guard valid, !Task.isCancelled else { return } + if self.filter != filter { + self.filter = filter; conversations = []; cursor = nil; cursors = []; loadedPages = 0 + hasMore = false; selectedIDs = []; loadID = UUID(); isLoading = false + } + await refresh() + } + + /// Revalidate only the pages this owner has loaded. Publish an entire + /// refreshed window atomically so returning to the menu never blanks it or + /// resets its pagination cursor while a request is in flight. + public func refresh() async { + guard valid, !isLoading, !Task.isCancelled else { return } + let ticket = UUID(), generation = revision, requested = filter; loadID = ticket + isLoading = true; issue = nil + defer { if valid, loadID == ticket { isLoading = false } } + do { + var results: [Conversation] = [], ids = Set(), visited = Set() + var after: String?, more = false, pageCount = 0 + for _ in 0.. Bool = { true }) async { + guard valid, filter == .recent, hasLoadedConversations, !isLoading, !isMutating, + !Task.isCancelled, shouldApply() + else { return } + let ticket = loadID, generation = revision + do { + let page = try await service.listConversations(parameters(.recent, after: nil)) + try Task.checkCancellation() + guard valid, loadID == ticket, revision == generation, !isLoading, !isMutating, shouldApply(), + !page.data.isEmpty, page.data.count <= 20 + else { return } + let cached = Dictionary(conversations.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + var merged: [Conversation] = [], ids = Set() + for value in page.data where ids.insert(value.id).inserted { + if let existing = cached[value.id], existing.metadata?["title"] == value.metadata?["title"] { + merged.append(existing) + } else { + merged.append(value) + } + } + for value in conversations where ids.insert(value.id).inserted { merged.append(value) } + if conversations != merged { conversations = merged } + for value in page.data { + deletions.updateProject(conversationID: value.id, projectID: value.projectId) + } + } catch { + // Periodic reads leave the previous complete menu and issue intact. + } + } + public func loadProjects() async { + guard valid, !isLoadingProjects, !Task.isCancelled else { return } + let ticket = UUID(), generation = revision; projectLoadID = ticket; isLoadingProjects = true + do { + var results: [ConversationProjectListItem] = [], after: String?, visited = Set() + for _ in 0..<100 { + let page = try await service.listProjects(ListParameters(limit: 100, after: after, order: .desc)) + try Task.checkCancellation() + guard valid, projectLoadID == ticket else { return } + guard generation == revision, !isMutating else { isLoadingProjects = false; return } + results += page.data + if !page.hasMore { + guard Set(results.map(\.id)).count == results.count else { throw LibraryIssue.invalidResponse } + if projects != results { projects = results } + isLoadingProjects = false; return + } + guard let next = page.lastId, !page.data.isEmpty, visited.insert(next).inserted else { + throw LibraryIssue.invalidResponse + } + after = next + } + throw LibraryIssue.invalidResponse + } catch is CancellationError { + if valid, projectLoadID == ticket { isLoadingProjects = false } + } catch { + if valid, projectLoadID == ticket { + if revision == generation { issue = LibraryIssue.present(error) } + isLoadingProjects = false + } + } + } + + public func renameConversation(id: String, title: String) async { + guard !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { issue = .invalidInput; return } + await mutate { [self] in + let original = try await service.getConversation(id: id); try check() + guard original.id == id else { throw LibraryIssue.invalidResponse } + var metadata = original.metadata ?? [:]; metadata["title"] = .string(title) + let updated = try await service.updateConversation( + id: id, metadata: metadata, projectId: .unchanged, pinned: nil) + try check(); guard updated.id == id else { throw LibraryIssue.invalidResponse }; publish(updated) + } + } + public func setPinned(id: String, pinned: Bool) async { + await mutate { [self] in + let updated = try await service.updateConversation( + id: id, metadata: nil, projectId: .unchanged, pinned: pinned) + try check(); guard updated.id == id else { throw LibraryIssue.invalidResponse }; publish(updated) + } + } + public func relocateConversation( + id: String, to destination: LibraryConversationDestination, from owner: AccountScope + ) async { + guard owner == scope else { return } + guard !id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + issue = .invalidInput; return + } + let project: NullableUpdate, pinned: Bool + switch destination { + case .project(let id): + guard !id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + issue = .invalidInput; return + } + project = .set(id); pinned = false + case .pinned: + project = .unchanged; pinned = true + case .recent: + project = .clear; pinned = false + } + await mutate { [self] in + // One authenticated update prevents an intermediate project/pin + // combination and uses the same revocable owner as other actions. + let updated = try await service.updateConversation( + id: id, metadata: nil, projectId: project, pinned: pinned) + try check() + guard updated.id == id, updated.pinned == pinned else { throw LibraryIssue.invalidResponse } + switch project { + case .set(let expected): + guard updated.projectId == expected else { throw LibraryIssue.invalidResponse } + case .clear: + guard updated.projectId == nil else { throw LibraryIssue.invalidResponse } + case .unchanged: break + } + publish(updated); selectedIDs.remove(id) + } + } + @discardableResult public func moveConversations(ids: Set, projectID: String?) async -> Bool { + guard !ids.isEmpty, ids.count <= 10_000 else { issue = .invalidInput; return false } + var completed = false + await mutate { [self] in + for batch in Self.batches(ids) { + let result = try await service.batchUpdateConversationProject(ids: batch, projectId: projectID) + try check(); guard result.success else { throw LibraryIssue.invalidResponse } + let changed = Set(batch) + for index in conversations.indices where changed.contains(conversations[index].id) { + conversations[index].projectId = projectID; onConversationUpdated?(conversations[index]) + } + let retained = conversations.filter { accepts($0) } + if conversations != retained { conversations = retained } + for id in batch { deletions.updateProject(conversationID: id, projectID: projectID) } + selectedIDs.subtract(changed) + // Bulk selection can span independently paged menu sections. + // Publish acknowledged membership even when this owner has no row snapshot. + onConversationProjectUpdated?(changed, projectID) + } + completed = true + } + return completed && valid + } + public func createProject(name: String) async -> ConversationProject? { + guard !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { issue = .invalidInput; return nil } + var result: ConversationProject? + await mutate { [self] in + let value = try await service.createProject(name: name); try check(); publish(value); result = value + } + return result + } + public func renameProject(id: String, name: String) async { + guard !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { issue = .invalidInput; return } + await mutate { [self] in + let value = try await service.updateProject(id: id, name: name, instructions: .unchanged) + try check(); guard value.id == id else { throw LibraryIssue.invalidResponse }; publish(value) + } + } + + @discardableResult public func deleteConversations(ids: Set) async -> Bool { + guard !ids.isEmpty, ids.count <= 10_000 else { issue = .invalidInput; return false } + var completed = false + await mutate { [self] in + let lease = try deletions.begin(.conversations(ids)); defer { deletions.release(lease) } + try await deletions.quiesce(lease); try check(); try deletions.assertQuiescent(lease) + var deletedAll = Set() + for batch in Self.batches(ids) { + let requested = Set(batch) + let response = try await service.batchDeleteConversations(ids: batch); try check() + guard Set(response.data.map(\.id)) == requested, response.data.count == batch.count else { + throw LibraryIssue.invalidResponse + } + let deleted = Set(response.data.filter(\.deleted).map(\.id)) + deletedAll.formUnion(deleted); remove(deleted, all: false) + try await deletions.finish(lease, deletedConversationIDs: deleted) + } + if deletedAll != ids { throw LibraryIssue.partialMutation } + completed = true + } + return completed && valid + } + public func deleteAllConversations() async { + await mutate { [self] in + let lease = try deletions.begin(.all); defer { deletions.release(lease) } + try await deletions.quiesce(lease); try check(); try deletions.assertQuiescent(lease) + let result = try await service.deleteAllConversations(); try check() + guard result.deleted else { throw LibraryIssue.invalidResponse } + remove(Set(conversations.map(\.id)), all: true) + try await deletions.finish(lease, deleteScope: true) + } + } + public func deleteProject(id: String) async { + await mutate { [self] in + let lease = try deletions.begin(.all); defer { deletions.release(lease) } + let ids = try await discoverProjectConversations(id); try check() + try deletions.retarget(lease, to: .project(id, knownConversationIDs: ids)) + try await deletions.quiesce(lease); try check(); try deletions.assertQuiescent(lease) + let result = try await service.deleteProject(id: id); try check() + guard result.deleted, result.id == nil || result.id == id else { throw LibraryIssue.invalidResponse } + projects.removeAll { $0.id == id }; remove(ids, all: false); onProjectDeleted?(id) + try await deletions.finish(lease, deleteScope: true) + } + } + + public func invalidate() { + valid = false; loadID = UUID(); projectLoadID = UUID(); revision &+= 1 + conversations = []; projects = []; selectedIDs = []; isLoading = false; isLoadingProjects = false; + isMutating = false; searchText = "" + } + + // Acknowledged changes from sibling lists share the exact account generation. + // Invalidate pending reads before reconciling, without starting another request. + public func receiveConversationUpdate(_ value: Conversation, from owner: AccountScope) { + guard valid, scope == owner else { return } + revision &+= 1 + if let index = conversations.firstIndex(where: { $0.id == value.id }) { + if accepts(value) { + if conversations[index] != value { conversations[index] = value } + } else { + conversations.remove(at: index) + } + } else if loadedPages > 0, accepts(value) { + conversations.insert(value, at: 0) + } + } + public func receiveProjectMembership(_ ids: Set, projectID: String?, from owner: AccountScope) { + guard valid, scope == owner else { return } + revision &+= 1 + for index in conversations.indices + where ids.contains(conversations[index].id) && conversations[index].projectId != projectID { + conversations[index].projectId = projectID + } + let retained = conversations.filter { accepts($0) } + if conversations != retained { conversations = retained } + } + public func receiveDeletion(_ ids: Set, all: Bool, from owner: AccountScope) { + guard valid, scope == owner else { return } + revision &+= 1 + let retained = conversations.filter { !all && !ids.contains($0.id) } + if conversations != retained { conversations = retained } + if all { selectedIDs = []; hasMore = false } else { selectedIDs.subtract(ids) } + } + public func receiveProjectUpdate(_ value: ConversationProject, from owner: AccountScope) { + guard valid, scope == owner else { return } + revision &+= 1; updateProjectSnapshot(value) + } + public func receiveProjectDeletion(_ id: String, from owner: AccountScope) { + guard valid, scope == owner else { return } + revision &+= 1 + if let index = projects.firstIndex(where: { $0.id == id }) { projects.remove(at: index) } + } + private func mutate(_ action: @escaping @MainActor () async throws -> Void) async { + guard valid, !isMutating, !deletions.hasActiveFence else { issue = valid ? .busy : .retired; return } + isMutating = true; issue = nil; revision &+= 1 + do { try await action() } catch { if valid { issue = LibraryIssue.present(error) } } + // Reads begun during the mutation may contain a pre-mutation snapshot. + // Settlement invalidates them even when they return after publication. + if valid { revision &+= 1; isMutating = false } + } + private func check() throws { guard valid else { throw LibraryIssue.retired }; try Task.checkCancellation() } + private static func batches(_ ids: Set) -> [[String]] { + let sorted = ids.sorted() + // The authenticated backend contract admits at most twenty IDs. + return stride(from: 0, to: sorted.count, by: 20).map { Array(sorted[$0.., all: Bool) { + conversations.removeAll { all || ids.contains($0.id) } + if all { selectedIDs = []; hasMore = false } else { selectedIDs.subtract(ids) } + onConversationsDeleted?(ids, all) + } + private func accepts(_ conversation: Conversation) -> Bool { + switch filter { + case .all: true + case .pinned: conversation.pinned + case .recent: conversation.projectId == nil && !conversation.pinned + case .unassigned: conversation.projectId == nil + case .project(let id): conversation.projectId == id + } + } + private func parameters(_ filter: LibraryFilter, after: String?) -> ConversationsListParameters { + // Research's shared web/desktop sidebar starts with twenty recent + // chats and requests ten older chats after the oldest loaded ID. + let limit = filter == .recent ? (after == nil ? 20 : 10) : 100 + let page = ListParameters(limit: limit, after: after, order: .desc) + switch filter { + case .all: return .init(page: page) + case .pinned: return .init(page: page, pinned: true) + case .recent: return .init(page: page, unassignedProject: true, pinned: false) + case .unassigned: return .init(page: page, unassignedProject: true) + case .project(let id): return .init(page: page, projectId: id) + } + } + private func discoverProjectConversations(_ projectID: String) async throws -> Set { + var result = Set(), after: String?, visited = Set() + for _ in 0..<100 { + let page = try await service.listConversations(parameters(.project(projectID), after: after)); try check() + guard page.data.allSatisfy({ $0.projectId == projectID }) else { throw LibraryIssue.invalidResponse } + result.formUnion(page.data.map(\.id)) + if !page.hasMore { return result } + guard let next = page.lastId, !page.data.isEmpty, visited.insert(next).inserted else { + throw LibraryIssue.invalidResponse + } + after = next + } + throw LibraryIssue.invalidResponse + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Library/LibraryProjectExpansionController.swift b/apps/maple-ios/Sources/MapleAppCore/Library/LibraryProjectExpansionController.swift new file mode 100644 index 000000000..fec4eebb4 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Library/LibraryProjectExpansionController.swift @@ -0,0 +1,120 @@ +import Foundation +import Observation + +/// One owner per account session survives menu-view recreation. A failed read +/// keeps the record untouched; folder interactions still work in memory until +/// the store can be read safely. Late restores merge explicit local choices. +@MainActor @Observable public final class LibraryProjectExpansionController { + public enum State: Equatable, Sendable { + case idle, loading, ready, failed(LibraryProjectExpansionIssue), retired + } + public let scope: AccountScope + public private(set) var state: State = .idle + public private(set) var saveIssue: LibraryProjectExpansionIssue? + private var expanded = Set() + private var pendingChoices: [String: Bool] = [:] + private let store: any LibraryProjectExpansionStoring + private var active = true + private var operation = UUID() + private var revision: UInt64 = 0 + private var saveTask: Task? + + public init(scope: AccountScope, store: any LibraryProjectExpansionStoring) { + self.scope = scope; self.store = store + } + + public func load() async { + guard active, state != .loading, state != .ready else { return } + let ticket = UUID(); operation = ticket; state = .loading + do { + var restored = try await store.activate(scope) + guard active, operation == ticket else { return } + for (digest, choice) in pendingChoices { + if choice { restored.insert(digest) } else { restored.remove(digest) } + } + guard restored.count <= LibraryProjectExpansionStore.maximumProjects else { + throw LibraryProjectExpansionIssue.invalidRecord + } + expanded = restored; state = .ready + if !pendingChoices.isEmpty { pendingChoices.removeAll(); enqueueSave() } + } catch { + guard active, operation == ticket else { return } + state = .failed(Self.safe(error)) + } + } + + public func isExpanded(id: String) -> Bool { + guard active, let digest = LibraryProjectExpansionStore.projectDigest(id) else { return false } + return expanded.contains(digest) + } + + @discardableResult public func setExpanded(id: String, expanded choice: Bool) -> Bool { + guard active, let digest = LibraryProjectExpansionStore.projectDigest(id) else { return false } + var next = expanded + if choice { next.insert(digest) } else { next.remove(digest) } + guard next.count <= LibraryProjectExpansionStore.maximumProjects else { return false } + if state != .ready { + guard + pendingChoices[digest] != nil + || pendingChoices.count < LibraryProjectExpansionStore.maximumProjects + else { return false } + pendingChoices[digest] = choice + } + guard next != expanded else { return true } + expanded = next + if state == .ready { enqueueSave() } + return true + } + + public func flush() async { await saveTask?.value } + + public func retrySave() { + guard active, state == .ready, saveIssue != nil else { return } + enqueueSave() + } + + public func prepareAccountDeletion() async throws { + guard active else { throw LibraryProjectExpansionIssue.sessionChanged } + await saveTask?.value + guard active else { throw LibraryProjectExpansionIssue.sessionChanged } + try await store.prepareRemoval(scope) + } + + /// A failed remote request can cancel its exact lease's marker even after + /// ordinary logout; replacement-generation markers remain fenced. + public func cancelAccountDeletion() async throws { try await store.cancelRemoval(scope) } + + /// Revoke UI mutations synchronously, before account retirement awaits. + public func invalidate() { + active = false; operation = UUID(); state = .retired + expanded.removeAll(); pendingChoices.removeAll() + } + + public func retire(removePersisted: Bool = false) async throws { + invalidate() + await saveTask?.value + try await store.retire(scope, removePersisted: removePersisted) + saveIssue = nil + } + + private func enqueueSave() { + revision += 1 + let nextRevision = revision, next = expanded, prior = saveTask + saveIssue = nil + saveTask = Task { [weak self, store, scope] in + await prior?.value + do { + try await store.save(next, scope: scope, revision: nextRevision) + guard let self, self.active, self.revision == nextRevision else { return } + self.saveIssue = nil + } catch { + guard let self, self.active, self.revision == nextRevision else { return } + self.saveIssue = Self.safe(error) + } + } + } + + private static func safe(_ error: any Error) -> LibraryProjectExpansionIssue { + error as? LibraryProjectExpansionIssue ?? .unavailable + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Library/LibraryProjectExpansionStore.swift b/apps/maple-ios/Sources/MapleAppCore/Library/LibraryProjectExpansionStore.swift new file mode 100644 index 000000000..fdbb3b9a9 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Library/LibraryProjectExpansionStore.swift @@ -0,0 +1,265 @@ +import Foundation + +public enum LibraryProjectExpansionIssue: Error, Equatable, Sendable { + case sessionChanged, invalidRecord, unsupportedVersion, protectedDataUnavailable, unavailable +} + +public protocol LibraryProjectExpansionStoring: Sendable { + func activate(_ scope: AccountScope) async throws -> Set + func save(_ digests: Set, scope: AccountScope, revision: UInt64) async throws + func prepareRemoval(_ scope: AccountScope) async throws + func cancelRemoval(_ scope: AccountScope) async throws + func retire(_ scope: AccountScope, removePersisted: Bool) async throws +} + +/// Local presentation metadata only, never a server preference. The separate +/// protected directory is excluded from backups. Records contain project-ID +/// digests, not identifiers, names, account details, or project content. Hashing +/// is namespacing and data minimization, not encryption or authorization. +public actor LibraryProjectExpansionStore: LibraryProjectExpansionStoring { + private struct Record: Codable { + let version: Int + let expanded: [String] + } + private struct Removal: Codable { let version: Int } + static let maximumProjects = 4_096 + static let maximumBytes = 300 * 1_024 + private let files: ProtectedChatFiles + private var protectionAvailable: Bool + private var active: AccountScope? + private var retired = Set() + private var latestGeneration: [String: UUID] = [:] + private var latestRevision: UInt64 = 0 + private var pendingRemoval = Set() + private var preparedOwners: [String: UUID] = [:] + private var pendingCancellations: [String: UUID] = [:] + private var startupCleaned = false + + public init(root: URL, protectionAvailable: Bool = true) { + files = ProtectedChatFiles(root: root.standardizedFileURL) + self.protectionAvailable = protectionAvailable + } + + public func setProtectedDataAvailable(_ available: Bool) { protectionAvailable = available } + + public func activate(_ scope: AccountScope) throws -> Set { + guard !retired.contains(scope), active == nil || active == scope else { + throw LibraryProjectExpansionIssue.sessionChanged + } + let account = try Self.accountName(scope) + if active == nil { latestRevision = 0 } + // A failed/locked read still owns a revocable session lease. Otherwise + // retirement could mistake this generation for a stale former owner. + active = scope; latestGeneration[account] = scope.generation + try requireProtection() + try finishStartupCleanup() + try finishRemovals() + try finishCancellations() + try finishPreparedHandoff(scope) + do { + return try files.withDirectory { directory in + for name in try files.entries(in: directory) + where ProtectedChatFiles.temporaryOwner(of: name) == account { + try files.remove(name, in: directory) + } + guard let data = try files.read(account + ".json", maximumBytes: Self.maximumBytes, in: directory) + else { return [] } + struct Version: Decodable { let version: Int } + let version = try JSONDecoder().decode(Version.self, from: data) + guard version.version == 1 else { throw LibraryProjectExpansionIssue.unsupportedVersion } + let record = try JSONDecoder().decode(Record.self, from: data) + let expanded = Set(record.expanded) + guard expanded.count == record.expanded.count else { + throw LibraryProjectExpansionIssue.invalidRecord + } + try Self.validate(expanded) + return expanded + } + } catch { throw Self.safe(error) } + } + + public func save(_ digests: Set, scope: AccountScope, revision: UInt64) throws { + guard active == scope, !retired.contains(scope), pendingRemoval.isEmpty else { + throw LibraryProjectExpansionIssue.sessionChanged + } + try requireProtection() + try finishCancellations() + try finishPreparedHandoff(scope) + try Self.validate(digests) + guard revision > latestRevision else { return } + let account = try Self.accountName(scope) + do { + let data = try JSONEncoder().encode(Record(version: 1, expanded: digests.sorted())) + guard data.count <= Self.maximumBytes else { throw LibraryProjectExpansionIssue.invalidRecord } + try files.withDirectory { directory in + try files.publish(data, name: account + ".json", account: account, directory: directory) {} + } + latestRevision = revision + } catch { throw Self.safe(error) } + } + + /// Called before the remote account-deletion request. A protected, tiny + /// marker survives termination while the request/result is uncertain; on + /// relaunch only these local presentation choices may be reset. + public func prepareRemoval(_ scope: AccountScope) throws { + guard active == scope, !retired.contains(scope), pendingRemoval.isEmpty else { + throw LibraryProjectExpansionIssue.sessionChanged + } + try requireProtection() + try finishStartupCleanup() + try finishCancellations() + let account = try Self.accountName(scope) + do { + let data = try JSONEncoder().encode(Removal(version: 1)) + try files.withDirectory { directory in + try files.publish(data, name: account + ".removal", account: account, directory: directory) {} + } + preparedOwners[account] = scope.generation + } catch { throw Self.safe(error) } + } + + public func cancelRemoval(_ scope: AccountScope) throws { + let account = try Self.accountName(scope) + try requireLatestGeneration(scope, account: account) + guard latestGeneration[account] == scope.generation else { + throw LibraryProjectExpansionIssue.sessionChanged + } + guard !pendingRemoval.contains(account) else { throw LibraryProjectExpansionIssue.sessionChanged } + // Record the already-authorized cancellation before a locked-device + // preflight; unlock can then remove this marker without losing choices. + pendingCancellations[account] = scope.generation + try requireProtection() + try finishCancellations() + } + + /// Run before publishing account-owned UI. Do not consume a marker for an + /// in-flight deletion in the current process. activate also performs this + /// startup pass before its first protected read, including after unlock. + public func startupCleanup() throws { + try requireProtection() + if active == nil { try finishStartupCleanup() } + try finishRemovals() + try finishCancellations() + if let active { try finishPreparedHandoff(active) } + } + + public func retire(_ scope: AccountScope, removePersisted: Bool = false) throws { + let account = try Self.accountName(scope) + try requireLatestGeneration(scope, account: account) + retired.insert(scope) + if active == scope { active = nil; latestRevision = 0 } + if removePersisted { pendingRemoval.insert(account) } + try finishRemovals() + } + + static func accountName(_ scope: AccountScope) throws -> String { + do { return String(try PreferencesStore.key(scope).suffix(64)) } catch { + throw LibraryProjectExpansionIssue.invalidRecord + } + } + + static func projectDigest(_ id: String) -> String? { + guard !id.isEmpty, id.utf8.count <= 1_024 else { return nil } + return ProtectedChatFiles.digest(Data(id.utf8)) + } + + private static func validate(_ digests: Set) throws { + guard digests.count <= maximumProjects, digests.allSatisfy(ProtectedChatFiles.isDigest) else { + throw LibraryProjectExpansionIssue.invalidRecord + } + } + + private func requireProtection() throws { + guard protectionAvailable else { throw LibraryProjectExpansionIssue.protectedDataUnavailable } + } + + private func requireLatestGeneration(_ scope: AccountScope, account: String) throws { + if let active, active != scope, try Self.accountName(active) == account { + throw LibraryProjectExpansionIssue.sessionChanged + } + guard latestGeneration[account] == nil || latestGeneration[account] == scope.generation else { + throw LibraryProjectExpansionIssue.sessionChanged + } + } + + private func finishStartupCleanup() throws { + guard !startupCleaned else { return } + do { + try files.withDirectory { directory in + for name in try files.entries(in: directory) { + guard name.hasSuffix(".removal"), name.utf8.count == 72 else { continue } + let account = String(name.prefix(64)) + guard ProtectedChatFiles.isDigest(account), + let data = try files.read(name, maximumBytes: 128, in: directory) + else { continue } + let marker = try JSONDecoder().decode(Removal.self, from: data) + guard marker.version == 1 else { throw LibraryProjectExpansionIssue.unsupportedVersion } + try removeRecord(account, directory: directory) + } + } + startupCleaned = true + } catch { throw Self.safe(error) } + } + + private func removeRecord(_ account: String, directory: Int32) throws { + try files.remove(account + ".json", in: directory) + for name in try files.entries(in: directory) + where ProtectedChatFiles.temporaryOwner(of: name) == account { + try files.remove(name, in: directory) + } + // The durable intent goes last, so interrupted cleanup retries safely. + try files.remove(account + ".removal", in: directory) + preparedOwners.removeValue(forKey: account) + pendingCancellations.removeValue(forKey: account) + } + + private func finishCancellations() throws { + guard !pendingCancellations.isEmpty else { return } + try requireProtection() + do { + try files.withDirectory { directory in + for (account, generation) in pendingCancellations { + // A newer prepared marker cannot be removed by an earlier + // generation's delayed cancellation, even after logout. + if preparedOwners[account] == generation, !pendingRemoval.contains(account) { + try files.remove(account + ".removal", in: directory) + preparedOwners.removeValue(forKey: account) + } + pendingCancellations.removeValue(forKey: account) + } + } + } catch { throw Self.safe(error) } + } + + private func finishPreparedHandoff(_ scope: AccountScope) throws { + let account = try Self.accountName(scope) + guard let prepared = preparedOwners[account], prepared != scope.generation else { return } + try requireProtection() + do { + // An uncertain old request may reset only the old generation's UI + // choices. Consume its marker before the replacement can read or + // publish, so a future restart cannot erase replacement choices. + try files.withDirectory { directory in try removeRecord(account, directory: directory) } + } catch { throw Self.safe(error) } + } + + private func finishRemovals() throws { + guard !pendingRemoval.isEmpty else { return } + try requireProtection() + do { + try files.withDirectory { directory in + for account in pendingRemoval { + try removeRecord(account, directory: directory) + } + } + pendingRemoval.removeAll() + } catch { throw Self.safe(error) } + } + + private static func safe(_ error: any Error) -> LibraryProjectExpansionIssue { + if let issue = error as? LibraryProjectExpansionIssue { return issue } + if error is DecodingError { return .invalidRecord } + if let issue = error as? ChatStorageError, issue == .capacityExceeded { return .invalidRecord } + return .unavailable + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Navigation/ExternalLinkPolicy.swift b/apps/maple-ios/Sources/MapleAppCore/Navigation/ExternalLinkPolicy.swift new file mode 100644 index 000000000..c61803e23 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Navigation/ExternalLinkPolicy.swift @@ -0,0 +1,25 @@ +import Foundation + +public enum ExternalLinkPolicy { + /// A displayed link cannot smuggle file access, script execution or embedded + /// credentials into either the embedded browser or a system handoff. + public static func isWeb(_ url: URL) -> Bool { + guard url.absoluteString.utf8.count <= 8_192, + let parts = URLComponents(url: url, resolvingAgainstBaseURL: false), + let scheme = parts.scheme?.lowercased(), ["https", "http"].contains(scheme), + let host = parts.host, !host.isEmpty, + parts.user == nil, parts.password == nil, + parts.port.map({ (1...65_535).contains($0) }) ?? true, + !url.absoluteString.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) + else { return false } + return true + } + + public static func isMail(_ url: URL) -> Bool { + guard url.absoluteString.utf8.count <= 4_096, url.scheme?.lowercased() == "mailto", + let decoded = url.absoluteString.removingPercentEncoding, + !decoded.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) + else { return false } + return !url.path.isEmpty + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Preferences/LocalPreferences.swift b/apps/maple-ios/Sources/MapleAppCore/Preferences/LocalPreferences.swift new file mode 100644 index 000000000..db3f055a5 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Preferences/LocalPreferences.swift @@ -0,0 +1,93 @@ +import Foundation +import MapleSDK + +public enum MapleTheme: String, Codable, CaseIterable, Sendable { case system, light, dark } +public enum MapleChatFont: String, Codable, CaseIterable, Sendable { case manrope, system, serif } + +/// Stable preference values shared with Research's chatThinkingLevel choice. +public enum MapleThinkingLevel: String, Codable, CaseIterable, Sendable { + case `default`, none, minimal, low, medium, high, xhigh, max + + public var effort: ReasoningEffort? { ReasoningEffort(rawValue: rawValue) } + public var label: String { + switch self { + case .default: "Default" + case .none: "Off" + case .minimal: "Minimal" + case .low: "Low" + case .medium: "Medium" + case .high: "High" + case .xhigh: "Extra high" + case .max: "Max" + } + } +} + +/// This is the playback rate. Request synthesis at speed 1 to avoid applying it twice. +public enum MaplePlaybackSpeed: Double, Codable, CaseIterable, Sendable { + case normal = 1, slightlyFast = 1.2, relaxedFast = 1.25, fast = 1.5, double = 2 +} + +/// The supported provider identifiers, deliberately independent of translated labels. +public enum MapleVoice: String, Codable, CaseIterable, Sendable { + case neutralFemale = "neutral_female", neutralMale = "neutral_male" + case casualFemale = "casual_female", casualMale = "casual_male", cheerfulFemale = "cheerful_female" + case arabicMale = "ar_male", germanFemale = "de_female", germanMale = "de_male" + case spanishFemale = "es_female", spanishMale = "es_male", frenchFemale = "fr_female", frenchMale = "fr_male" + case hindiFemale = "hi_female", hindiMale = "hi_male", italianFemale = "it_female", italianMale = "it_male" + case dutchFemale = "nl_female", dutchMale = "nl_male", portugueseFemale = "pt_female", portugueseMale = "pt_male" +} + +/// Nonsensitive device preferences only. Prompts, projects, drafts, credentials, +/// playback ownership and autoplay must never be added to this record. +public struct LocalPreferences: Codable, Equatable, Sendable { + public var theme: MapleTheme + public var chatFont: MapleChatFont + public var selectedModelID: String + public var webSearchPreferred: Bool + public var thinkingLevel: MapleThinkingLevel + public var voice: MapleVoice + public var playbackSpeed: MaplePlaybackSpeed + public var sidebarIsOpen: Bool + + public init( + theme: MapleTheme = .system, chatFont: MapleChatFont = .manrope, + selectedModelID: String = "auto:quick", webSearchPreferred: Bool = true, + thinkingLevel: MapleThinkingLevel = .default, + voice: MapleVoice = .casualFemale, playbackSpeed: MaplePlaybackSpeed = .slightlyFast, + sidebarIsOpen: Bool = true + ) { + self.theme = theme; self.chatFont = chatFont; self.selectedModelID = selectedModelID + self.webSearchPreferred = webSearchPreferred; self.voice = voice; self.playbackSpeed = playbackSpeed + self.thinkingLevel = thinkingLevel + self.sidebarIsOpen = sidebarIsOpen + } + + private enum CodingKeys: String, CodingKey { + case theme, chatFont, selectedModelID, webSearchPreferred, voice, playbackSpeed, sidebarIsOpen + case thinkingLevel = "chatThinkingLevel" + } + + public init(from decoder: any Decoder) throws { + let record = try decoder.container(keyedBy: CodingKeys.self) + theme = try record.decode(MapleTheme.self, forKey: .theme) + chatFont = try record.decode(MapleChatFont.self, forKey: .chatFont) + selectedModelID = try record.decode(String.self, forKey: .selectedModelID) + webSearchPreferred = try record.decodeIfPresent(Bool.self, forKey: .webSearchPreferred) ?? true + // Research treats an absent or obsolete stored thinking choice as Default. + let storedLevel = try record.decodeIfPresent(String.self, forKey: .thinkingLevel) + thinkingLevel = storedLevel.flatMap(MapleThinkingLevel.init(rawValue:)) ?? .default + voice = try record.decode(MapleVoice.self, forKey: .voice) + playbackSpeed = try record.decode(MaplePlaybackSpeed.self, forKey: .playbackSpeed) + // Existing records predate the sidebar choice; their selected speech rate remains authoritative. + sidebarIsOpen = record.contains(.sidebarIsOpen) ? try record.decode(Bool.self, forKey: .sidebarIsOpen) : true + } + + public static func isValidModelID(_ value: String) -> Bool { + !value.isEmpty && value.utf8.count <= 256 + && value.utf8.allSatisfy { + (48...57).contains($0) || (65...90).contains($0) || (97...122).contains($0) + || [45, 46, 47, 58, 95].contains($0) + } + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Preferences/PreferencesController.swift b/apps/maple-ios/Sources/MapleAppCore/Preferences/PreferencesController.swift new file mode 100644 index 000000000..649436730 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Preferences/PreferencesController.swift @@ -0,0 +1,96 @@ +import Foundation +import Observation + +@MainActor @Observable public final class PreferencesController { + public enum State: Equatable, Sendable { case idle, loading, ready, failed(PreferencesIssue), retired } + public let scope: AccountScope + public private(set) var value = LocalPreferences() + public private(set) var state: State = .idle + public private(set) var saveIssue: PreferencesIssue? + public var onChange: (@MainActor (LocalPreferences) -> Void)? + private let store: any PreferencesStoring + private var active = true + private var operation = UUID() + private var revision: UInt64 = 0 + private var saveTask: Task? + + public init(scope: AccountScope, store: any PreferencesStoring) { self.scope = scope; self.store = store } + + public func load() async { + guard active, state != .loading, state != .ready else { return } + let ticket = UUID(); operation = ticket; state = .loading + do { + let loaded = try await store.activate(scope) + guard active, operation == ticket else { return } + value = loaded; state = .ready; onChange?(loaded) + } catch { + guard active, operation == ticket else { return } + state = .failed(Self.safe(error)) + } + } + + @discardableResult public func setTheme(_ theme: MapleTheme) -> Bool { update { $0.theme = theme } } + @discardableResult public func setChatFont(_ font: MapleChatFont) -> Bool { update { $0.chatFont = font } } + @discardableResult public func setVoice(_ voice: MapleVoice) -> Bool { update { $0.voice = voice } } + @discardableResult public func setPlaybackSpeed(_ speed: MaplePlaybackSpeed) -> Bool { + update { $0.playbackSpeed = speed } + } + @discardableResult public func setWebSearchPreferred(_ enabled: Bool) -> Bool { + update { $0.webSearchPreferred = enabled } + } + @discardableResult public func setThinkingLevel(_ level: MapleThinkingLevel) -> Bool { + update { $0.thinkingLevel = level } + } + @discardableResult public func setSidebarIsOpen(_ isOpen: Bool) -> Bool { + update { $0.sidebarIsOpen = isOpen } + } + @discardableResult public func setSelectedModelID(_ id: String) -> Bool { + guard LocalPreferences.isValidModelID(id) else { return false } + return update { $0.selectedModelID = id } + } + + /// Await before controlled lifecycle transitions. UserDefaults itself owns + /// asynchronous disk flushing; this drains application writes, not an fsync. + public func flush() async { await saveTask?.value } + + public func retrySave() { + guard active, state == .ready, saveIssue != nil else { return } + enqueueSave() + } + + public func retire(removePersisted: Bool = false) async throws { + active = false; operation = UUID(); state = .retired; onChange = nil + await saveTask?.value + // Store retirement rejects even uncancellable work that arrives later. + try await store.retire(scope, removePersisted: removePersisted) + value = LocalPreferences(); saveIssue = nil + } + + private func update(_ mutation: (inout LocalPreferences) -> Void) -> Bool { + guard active, state == .ready else { return false } + var next = value; mutation(&next) + guard next != value else { return true } + value = next; onChange?(next); enqueueSave(); return true + } + + private func enqueueSave() { + revision += 1 + let nextRevision = revision, next = value, prior = saveTask + saveIssue = nil + saveTask = Task { [weak self, store, scope] in + await prior?.value + do { + try await store.save(next, scope: scope, revision: nextRevision) + guard let self, self.active, self.revision == nextRevision else { return } + self.saveIssue = nil + } catch { + guard let self, self.active, self.revision == nextRevision else { return } + self.saveIssue = Self.safe(error) + } + } + } + + private static func safe(_ error: any Error) -> PreferencesIssue { + error as? PreferencesIssue ?? .unavailable + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Preferences/PreferencesStore.swift b/apps/maple-ios/Sources/MapleAppCore/Preferences/PreferencesStore.swift new file mode 100644 index 000000000..9e85a10ae --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Preferences/PreferencesStore.swift @@ -0,0 +1,87 @@ +import CryptoKit +import Foundation + +public enum PreferencesIssue: Error, Equatable, Sendable { + case sessionChanged, invalidRecord, unsupportedVersion, unavailable +} + +public protocol PreferencesStoring: Sendable { + func activate(_ scope: AccountScope) async throws -> LocalPreferences + func save(_ value: LocalPreferences, scope: AccountScope, revision: UInt64) async throws + func retire(_ scope: AccountScope, removePersisted: Bool) async throws +} + +/// Own one instance for the application lifetime. UserDefaults stays inside the +/// application's sandbox, without cloud synchronization. Ordinary logout retains +/// these nonsensitive choices; confirmed account deletion removes its exact key. +/// Hashing is namespacing, not encryption. No principal or origin is in the record. +public actor PreferencesStore: PreferencesStoring { + private struct Record: Codable { let version: Int; let preferences: LocalPreferences } + private let defaults: UserDefaults + private var active: AccountScope? + private var retired = Set() + private var latestGeneration: [String: UUID] = [:] + private var latestRevision: UInt64 = 0 + + public init(suiteName: String? = nil) { + defaults = suiteName.flatMap(UserDefaults.init(suiteName:)) ?? .standard + } + + public func activate(_ scope: AccountScope) throws -> LocalPreferences { + guard !retired.contains(scope), active == nil || active == scope else { + throw PreferencesIssue.sessionChanged + } + let key = try Self.key(scope) + if active == nil { latestRevision = 0 } + active = scope + latestGeneration[key] = scope.generation + guard let object = defaults.object(forKey: key) else { return LocalPreferences() } + guard let data = object as? Data, data.count <= 4_096 else { throw PreferencesIssue.invalidRecord } + do { + let record = try JSONDecoder().decode(Record.self, from: data) + guard record.version == 1 else { throw PreferencesIssue.unsupportedVersion } + guard LocalPreferences.isValidModelID(record.preferences.selectedModelID) else { + throw PreferencesIssue.invalidRecord + } + return record.preferences + } catch let issue as PreferencesIssue { throw issue } catch { throw PreferencesIssue.invalidRecord } + } + + public func save(_ value: LocalPreferences, scope: AccountScope, revision: UInt64) throws { + guard active == scope, !retired.contains(scope) else { throw PreferencesIssue.sessionChanged } + guard LocalPreferences.isValidModelID(value.selectedModelID) else { throw PreferencesIssue.invalidRecord } + // An older scheduled save cannot replace a newer explicit choice. + guard revision > latestRevision else { return } + let data = try JSONEncoder().encode(Record(version: 1, preferences: value)) + guard data.count <= 4_096 else { throw PreferencesIssue.invalidRecord } + defaults.set(data, forKey: try Self.key(scope)) + latestRevision = revision + } + + public func retire(_ scope: AccountScope, removePersisted: Bool = false) throws { + // Late cleanup must never remove a replacement generation's preferences. + let key = try Self.key(scope) + if let active, active != scope, try Self.key(active) == key { + throw PreferencesIssue.sessionChanged + } + guard latestGeneration[key] == nil || latestGeneration[key] == scope.generation else { + throw PreferencesIssue.sessionChanged + } + retired.insert(scope) + if active == scope { active = nil; latestRevision = 0 } + if removePersisted { defaults.removeObject(forKey: key) } + } + + static func key(_ scope: AccountScope) throws -> String { + guard !scope.apiOrigin.isEmpty, scope.apiOrigin.utf8.count <= 2_048, + !scope.principalID.isEmpty, scope.principalID.utf8.count <= 1_024 + else { throw PreferencesIssue.invalidRecord } + var input = Data() + for field in [scope.apiOrigin, scope.principalID] { + var count = UInt64(field.utf8.count).bigEndian + withUnsafeBytes(of: &count) { input.append(contentsOf: $0) } + input.append(contentsOf: field.utf8) + } + return "maple.native.preferences.v1." + SHA256.hash(data: input).map { String(format: "%02x", $0) }.joined() + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/SDKChatService.swift b/apps/maple-ios/Sources/MapleAppCore/SDKChatService.swift new file mode 100644 index 000000000..b04a40f30 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/SDKChatService.swift @@ -0,0 +1,32 @@ +import Foundation +import MapleSDK + +/// The captured SDK API holds its original credential generation throughout the +/// chat's lifetime. It cannot acquire a replacement account's authorization. +public struct SDKChatService: ChatServing { + private let api: ResearchAPI + public init(api: ResearchAPI) { self.api = api } + public func createConversation(metadata: [String: JSONValue]?, projectId: String?, pinned: Bool?) async throws + -> Conversation + { + try await api.createConversation(metadata: metadata, projectId: projectId, pinned: pinned) + } + public func createResponse(_ request: ResponsesCreateRequest, maximumInferenceSends: Int) async throws + -> AsyncThrowingStream + { + try await api.createResponse(request, maximumInferenceSends: maximumInferenceSends) + } + public func retrieveResponse(id: String) async throws -> ResearchResponse { try await api.retrieveResponse(id: id) } + public func listConversations(_ parameters: ConversationsListParameters) async throws -> APIPage { + try await api.listConversations(parameters) + } + public func cancelResponse(id: String) async throws -> ResearchResponse { try await api.cancelResponse(id: id) } + public func getConversationItem(conversationId: String, itemId: String) async throws -> ConversationItem { + try await api.getConversationItem(conversationId: conversationId, itemId: itemId) + } + public func listConversationItems(conversationId: String, parameters: ListParameters) async throws -> APIPage< + ConversationItem + > { + try await api.listConversationItems(conversationId: conversationId, parameters: parameters) + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Storage/ProtectedChatFiles.swift b/apps/maple-ios/Sources/MapleAppCore/Storage/ProtectedChatFiles.swift new file mode 100644 index 000000000..e172cdfd0 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Storage/ProtectedChatFiles.swift @@ -0,0 +1,194 @@ +import CryptoKit +import Darwin +import Foundation + +/// Descriptor-relative I/O keeps a checked directory or file from being swapped +/// for a link between validation and access. All names are generated here. +struct ProtectedChatFiles { + let root: URL + static let maximumDirectoryEntries = 8_192 + + static func digest(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + static func isDigest(_ value: String) -> Bool { + value.utf8.count == 64 && value.utf8.allSatisfy { (48...57).contains($0) || (97...102).contains($0) } + } + + static func accountName(_ scope: AccountScope) -> String { + digest(Data((scope.apiOrigin + "\u{0}" + scope.principalID).utf8)) + } + + static func owner(of name: String) -> String? { + let account = String(name.prefix(64)) + guard isDigest(account) else { return nil } + if name == account + ".json" { return account } + if name.hasPrefix(account + "-"), name.hasSuffix(".blob"), name.count == 134, + isDigest(String(name.dropFirst(65).dropLast(5))) + { + return account + } + return nil + } + + static func temporaryOwner(of name: String) -> String? { + guard name.hasPrefix(".pending-"), name.count == 110 else { return nil } + let tail = name.dropFirst(9) + let account = String(tail.prefix(64)) + guard isDigest(account), tail.dropFirst(64).first == "-", + UUID(uuidString: String(tail.dropFirst(65))) != nil + else { return nil } + return account + } + + func withDirectory(_ body: (Int32) throws -> T) throws -> T { + var existing = stat() + if lstat(root.path, &existing) != 0 { + guard errno == ENOENT else { throw ChatStorageError.unsafePath } + try FileManager.default.createDirectory( + at: root, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + } + let descriptor = open(root.path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + guard descriptor >= 0 else { throw ChatStorageError.unsafePath } + defer { close(descriptor) } + var info = stat() + guard fstat(descriptor, &info) == 0, info.st_mode & S_IFMT == S_IFDIR, + info.st_uid == geteuid(), fchmod(descriptor, 0o700) == 0 + else { + throw ChatStorageError.unsafePath + } + // The app sandbox owns the ancestors. Verify the leaf still identifies + // the opened directory before applying URL-only platform attributes. + guard lstat(root.path, &existing) == 0, existing.st_dev == info.st_dev, + existing.st_ino == info.st_ino + else { throw ChatStorageError.unsafePath } + try protect(root) + return try body(descriptor) + } + + func entries(in directory: Int32) throws -> [String] { + let duplicate = dup(directory) + guard duplicate >= 0 else { throw ChatStorageError.cleanupRequired } + guard let stream = fdopendir(duplicate) else { + close(duplicate) + throw ChatStorageError.cleanupRequired + } + defer { closedir(stream) } + rewinddir(stream) + var names: [String] = [] + while true { + errno = 0 + guard let entry = readdir(stream) else { + guard errno == 0 else { throw ChatStorageError.cleanupRequired } + return names + } + let name = withUnsafePointer(to: &entry.pointee.d_name) { + $0.withMemoryRebound(to: CChar.self, capacity: Int(NAME_MAX) + 1) { String(cString: $0) } + } + guard name != ".", name != ".." else { continue } + guard names.count < Self.maximumDirectoryEntries else { throw ChatStorageError.capacityExceeded } + names.append(name) + } + } + + func exists(_ name: String, in directory: Int32) throws -> Bool { + var info = stat() + if fstatat(directory, name, &info, AT_SYMLINK_NOFOLLOW) == 0 { + try validate(info) + return true + } + guard errno == ENOENT else { throw ChatStorageError.unsafePath } + return false + } + + func read(_ name: String, maximumBytes: Int, in directory: Int32) throws -> Data? { + let descriptor = openat(directory, name, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC) + if descriptor < 0 { + guard errno == ENOENT else { throw ChatStorageError.unsafePath } + return nil + } + defer { close(descriptor) } + var before = stat() + guard fstat(descriptor, &before) == 0 else { throw ChatStorageError.corruptData } + try validate(before) + guard fchmod(descriptor, 0o600) == 0 else { throw ChatStorageError.unsafePath } + guard before.st_size >= 0, before.st_size <= maximumBytes else { throw ChatStorageError.capacityExceeded } + guard fstat(descriptor, &before) == 0 else { throw ChatStorageError.corruptData } + var data = Data(count: Int(before.st_size)) + try data.withUnsafeMutableBytes { bytes in + guard let start = bytes.baseAddress else { return } + var offset = 0 + while offset < bytes.count { + let count = Darwin.read(descriptor, start.advanced(by: offset), bytes.count - offset) + if count < 0 && errno == EINTR { continue } + guard count > 0 else { throw ChatStorageError.corruptData } + offset += count + } + } + var after = stat() + guard fstat(descriptor, &after) == 0, before.st_size == after.st_size, + before.st_mtimespec.tv_sec == after.st_mtimespec.tv_sec, + before.st_mtimespec.tv_nsec == after.st_mtimespec.tv_nsec, + before.st_ctimespec.tv_sec == after.st_ctimespec.tv_sec, + before.st_ctimespec.tv_nsec == after.st_ctimespec.tv_nsec + else { throw ChatStorageError.corruptData } + try validate(after) + return data + } + + func remove(_ name: String, in directory: Int32) throws { + guard try exists(name, in: directory) else { return } + // unlinkat removes this directory entry; it never follows a replacement + // symlink or hardlink to delete or write its target. + guard unlinkat(directory, name, 0) == 0 || errno == ENOENT else { throw ChatStorageError.cleanupRequired } + } + + func publish( + _ data: Data, name: String, account: String, directory: Int32, + beforePublication: () throws -> Void + ) throws { + _ = try exists(name, in: directory) + let temporary = ".pending-" + account + "-" + UUID().uuidString + let descriptor = openat( + directory, temporary, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, S_IRUSR | S_IWUSR) + guard descriptor >= 0 else { throw ChatStorageError.writeFailed } + defer { + close(descriptor) + unlinkat(directory, temporary, 0) + } + try protect(root.appendingPathComponent(temporary)) + try data.withUnsafeBytes { bytes in + guard let start = bytes.baseAddress else { return } + var offset = 0 + while offset < bytes.count { + let count = Darwin.write(descriptor, start.advanced(by: offset), bytes.count - offset) + if count < 0 && errno == EINTR { continue } + guard count > 0 else { throw ChatStorageError.writeFailed } + offset += count + } + } + guard fsync(descriptor) == 0 else { throw ChatStorageError.writeFailed } + try beforePublication() + guard renameat(directory, temporary, directory, name) == 0 else { throw ChatStorageError.writeFailed } + // Publication has succeeded. A directory flush has platform-dependent + // support and cannot turn an already committed snapshot into a failure. + _ = fsync(directory) + } + + private func validate(_ info: stat) throws { + guard info.st_mode & S_IFMT == S_IFREG, info.st_nlink == 1, info.st_uid == geteuid() else { + throw ChatStorageError.unsafePath + } + } + + private func protect(_ url: URL) throws { + var target = url + var values = URLResourceValues() + values.isExcludedFromBackup = true + try target.setResourceValues(values) + #if os(iOS) + try FileManager.default.setAttributes([.protectionKey: FileProtectionType.complete], ofItemAtPath: url.path) + #endif + } +} diff --git a/apps/maple-ios/Sources/MapleAppCore/Storage/ProtectedChatStore.swift b/apps/maple-ios/Sources/MapleAppCore/Storage/ProtectedChatStore.swift new file mode 100644 index 000000000..bf4f728cc --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppCore/Storage/ProtectedChatStore.swift @@ -0,0 +1,412 @@ +import Foundation +import Synchronization + +public enum ChatStorageError: Error, Equatable, Sendable { + case retiredOwner, protectedDataUnavailable, unsafePath, corruptData, unsupportedVersion + case staleRevision, destinationOccupied, capacityExceeded, writeFailed, cleanupRequired +} + +/// One atomic account manifest keeps draft-to-conversation moves and queue edits +/// coherent. Images are immutable account-owned blobs; this is never a transcript +/// cache. Version 1 inline data is read and migrated on the next successful save. +public actor ProtectedChatStore: ChatPersistence { + struct Limits: Sendable { + var metadataBytes = 16 * 1_024 * 1_024 + var imageBytes = 20 * 1_024 * 1_024 + var accountImageBytes = 256 * 1_024 * 1_024 + var retainedImages = 640 + var imagesPerDraft = 10 + var queuedPerChat = 16 + var accountQueuedTurns = 64 + var queuedTextBytes = 32 * 1_024 + } + + enum Publication: Equatable, Sendable { case image, manifest } + + private struct Move: Codable { + let destination: String + } + + private struct Envelope: Codable { + var version = 2 + let origin: String + let principal: String + var chats: [String: ChatSnapshot] + // Optional for the pre-release version 1 envelope. Persisted tombstones + // fence old New Chat storage owners after relaunch, while allowing a fresh draft. + var moves: [String: [String: Move]]? + } + + private struct DecodedImages: Sendable { + var referenced: Set = [] + var bytes = 0 + var images: [String: Data] = [:] + } + + private struct Blob: Codable { + let sha256: String + let count: Int + } + + private let files: ProtectedChatFiles + private let limits: Limits + private let beforePublication: @Sendable (Publication) throws -> Void + private var active: AccountScope? + private var retired: Set = [] + private var pendingCleanup: String? + private var pendingSignedOutCleanup = false + private var protectionAvailable: Bool + private var envelope: Envelope? + private var referencedImages: Set = [] + + public init(root: URL, maximumBytes: Int = 16 * 1_024 * 1_024, protectionAvailable: Bool = true) { + files = ProtectedChatFiles(root: root.standardizedFileURL) + limits = Limits(metadataBytes: maximumBytes) + beforePublication = { _ in } + self.protectionAvailable = protectionAvailable + } + + init( + root: URL, limits: Limits, protectionAvailable: Bool = true, + beforePublication: @escaping @Sendable (Publication) throws -> Void + ) { + files = ProtectedChatFiles(root: root.standardizedFileURL) + self.limits = limits + self.beforePublication = beforePublication + self.protectionAvailable = protectionAvailable + } + + public func setProtectedDataAvailable(_ available: Bool) { protectionAvailable = available } + + public func activate(_ scope: AccountScope) throws { + guard !retired.contains(scope), active == nil || active == scope else { throw ChatStorageError.retiredOwner } + try requireProtection() + try finishCleanup() + try finishSignedOutCleanup() + if active == scope { return } + let account = ProtectedChatFiles.accountName(scope) + let limits = self.limits + let files = self.files + let loaded = try files.withDirectory { directory in + try removeTemporaries(account: account, directory: directory) + guard let data = try files.read(account + ".json", maximumBytes: limits.metadataBytes, in: directory) else { + try pruneImages(account: account, keeping: [], directory: directory) + return (Envelope(origin: scope.apiOrigin, principal: scope.principalID, chats: [:]), Set()) + } + struct Version: Decodable { let version: Int } + let version: Int + do { version = try JSONDecoder().decode(Version.self, from: data).version } catch { + throw ChatStorageError.corruptData + } + guard version == 1 || version == 2 else { throw ChatStorageError.unsupportedVersion } + let decoder = JSONDecoder() + let decoded = Mutex(DecodedImages()) + if version == 2 { + decoder.dataDecodingStrategy = .custom { decoder in + let blob = try Blob(from: decoder) + return try decoded.withLock { state in + guard ProtectedChatFiles.isDigest(blob.sha256), blob.count >= 0, + blob.count <= limits.imageBytes + else { + throw ChatStorageError.capacityExceeded + } + guard + let bytes = try files.read( + account + "-" + blob.sha256 + ".blob", maximumBytes: limits.imageBytes, in: directory), + bytes.count == blob.count, ProtectedChatFiles.digest(bytes) == blob.sha256 + else { + throw ChatStorageError.corruptData + } + state.images[blob.sha256] = bytes + state.bytes += bytes.count + state.referenced.insert(blob.sha256) + return bytes + } + } + } + let loaded: Envelope + do { loaded = try decoder.decode(Envelope.self, from: data) } catch let error as ChatStorageError { + throw error + } catch { throw ChatStorageError.corruptData } + guard loaded.origin == scope.apiOrigin, loaded.principal == scope.principalID, + loaded.chats.allSatisfy({ $0.key == $0.value.storageKey }) + else { throw ChatStorageError.corruptData } + try validateDrafts(loaded) + let referenced = decoded.withLock { $0.referenced } + try pruneImages(account: account, keeping: referenced, directory: directory) + return (loaded, referenced) + } + active = scope + envelope = loaded.0 + referencedImages = loaded.1 + } + + public func load(chatID: String, scope: AccountScope) throws -> ChatSnapshot? { + try requireOwner(scope) + try requireProtection() + return envelope?.chats[chatID] + } + + public func loadAll(scope: AccountScope) throws -> [ChatSnapshot] { + try requireOwner(scope) + try requireProtection() + return (envelope?.chats.values.map { $0 } ?? []).sorted { $0.storageKey < $1.storageKey } + } + + public func save(_ snapshot: ChatSnapshot, scope: AccountScope) throws { + try requireOwner(scope) + try requireProtection() + guard var next = envelope else { throw ChatStorageError.retiredOwner } + guard next.moves?[snapshot.storageKey]?[snapshot.storageOwner.uuidString] == nil else { + throw ChatStorageError.staleRevision + } + if let prior = next.chats[snapshot.storageKey] { + guard prior.storageOwner == snapshot.storageOwner else { throw ChatStorageError.staleRevision } + guard snapshot.revision > prior.revision || snapshot == prior else { throw ChatStorageError.staleRevision } + } + next.chats[snapshot.storageKey] = snapshot + try commit(next, scope: scope) + } + + public func rekey(from source: String, to snapshot: ChatSnapshot, scope: AccountScope) throws { + try requireOwner(scope) + try requireProtection() + guard var next = envelope else { throw ChatStorageError.retiredOwner } + guard source != snapshot.storageKey else { try save(snapshot, scope: scope); return } + let ownerID = snapshot.storageOwner.uuidString + if let move = next.moves?[source]?[ownerID] { + guard move.destination == snapshot.storageKey, let destination = next.chats[snapshot.storageKey] else { + throw ChatStorageError.staleRevision + } + guard destination.storageOwner == snapshot.storageOwner else { throw ChatStorageError.destinationOccupied } + // A retried move may arrive after destination edits or after a fresh + // New Chat draft exists. Never rewind either of those owners. + if destination.revision == snapshot.revision { + guard destination == snapshot else { throw ChatStorageError.staleRevision } + return + } + if destination.revision > snapshot.revision { return } + next.chats[snapshot.storageKey] = snapshot + try commit(next, scope: scope) + return + } + guard next.chats[snapshot.storageKey] == nil else { throw ChatStorageError.destinationOccupied } + guard let original = next.chats[source], + original.storageOwner == snapshot.storageOwner, + snapshot.revision > original.revision + else { throw ChatStorageError.staleRevision } + next.chats.removeValue(forKey: source) + next.chats[snapshot.storageKey] = snapshot + var moves = next.moves ?? [:] + moves[source, default: [:]][ownerID] = Move(destination: snapshot.storageKey) + next.moves = moves + try commit(next, scope: scope) + } + + public func remove(chatID: String, scope: AccountScope) throws { + try requireOwner(scope) + try requireProtection() + guard var next = envelope else { throw ChatStorageError.retiredOwner } + next.chats.removeValue(forKey: chatID) + try commit(next, scope: scope) + } + + /// Revoke before fallible deletion. Failed cleanup blocks any replacement + /// account lease until every recognized file of the retired owner is removed. + public func retire(_ scope: AccountScope) throws { + guard active == nil || active == scope else { throw ChatStorageError.retiredOwner } + retired.insert(scope) + if active == scope { + active = nil + envelope = nil + referencedImages = [] + pendingCleanup = ProtectedChatFiles.accountName(scope) + } + try finishCleanup() + } + + /// No saved credential authority exists. Unknown names remain untouched; + /// recognized but unsafe entries fail closed instead of following links. + public func clearSignedOutData() throws { + guard active == nil else { throw ChatStorageError.retiredOwner } + pendingSignedOutCleanup = true + try requireProtection() + try finishCleanup() + try finishSignedOutCleanup() + } + + private func finishSignedOutCleanup() throws { + guard pendingSignedOutCleanup else { return } + try requireProtection() + do { + try files.withDirectory { directory in + for name in try files.entries(in: directory) + where ProtectedChatFiles.owner(of: name) != nil || ProtectedChatFiles.temporaryOwner(of: name) != nil { + try files.remove(name, in: directory) + } + } + } catch { throw ChatStorageError.cleanupRequired } + pendingSignedOutCleanup = false + } + + private func requireOwner(_ scope: AccountScope) throws { + guard active == scope, !retired.contains(scope), pendingCleanup == nil, !pendingSignedOutCleanup else { + throw ChatStorageError.retiredOwner + } + } + + private func requireProtection() throws { + guard protectionAvailable else { throw ChatStorageError.protectedDataUnavailable } + } + + private func validateDrafts(_ value: Envelope) throws { + var bytes = 0 + var count = 0 + try validateQueues(value) + var seen: [UUID: [ChatImageAttachment]] = [:] + for snapshot in value.chats.values { + for draft in snapshot.ownedDrafts { + guard draft.images.count <= limits.imagesPerDraft else { throw ChatStorageError.capacityExceeded } + for image in draft.images { + // Queue original/working/held values can share immutable + // images. Reusing an ID for different bytes does not make + // that second retained version free or replace the first. + if seen[image.id]?.contains(image) == true { continue } + guard image.data.count <= limits.imageBytes, count < limits.retainedImages, + image.data.count <= limits.accountImageBytes - bytes + else { throw ChatStorageError.capacityExceeded } + seen[image.id, default: []].append(image) + bytes += image.data.count + count += 1 + } + } + } + } + + private func validateQueues(_ value: Envelope) throws { + var retained: [UUID: (chat: String, turn: ChatQueuedTurn)] = [:] + for snapshot in value.chats.values { + let queued = snapshot.queue?.items ?? [] + let recoveredHead = snapshot.queue?.recoveredHeadID + let waitingLimit = limits.queuedPerChat + (recoveredHead == nil ? 0 : 1) + guard queued.count <= waitingLimit else { throw ChatStorageError.capacityExceeded } + if let recoveredHead { + guard queued.count == limits.queuedPerChat + 1, queued.first?.id == recoveredHead, + snapshot.pendingRun == nil + else { throw ChatStorageError.corruptData } + } + if let run = snapshot.pendingRun { + guard Self.isMessageID(run.messageID), + run.queuedTurn == nil || run.queuedTurn?.messageID == run.messageID + else { + throw ChatStorageError.corruptData + } + } + guard Set(queued.map(\.id)).count == queued.count else { throw ChatStorageError.corruptData } + var turns = queued + if let edit = snapshot.queue?.edit { + guard queued.indices.contains(edit.originalIndex), queued[edit.originalIndex] == edit.original else { + throw ChatStorageError.corruptData + } + turns.append(edit.original) + } + // The current popped FIFO item still occupies account capacity. The + // per-chat limit applies to the waiting list, matching Research. + if let active = snapshot.pendingRun?.queuedTurn { turns.append(active) } + for turn in turns { + guard Self.isMessageID(turn.messageID) else { throw ChatStorageError.corruptData } + guard turn.draft.text.utf8.count <= limits.queuedTextBytes else { + throw ChatStorageError.capacityExceeded + } + if let prior = retained[turn.id] { + guard prior.chat == snapshot.storageKey, prior.turn == turn else { + throw ChatStorageError.corruptData + } + } else { + guard retained.count < limits.accountQueuedTurns else { throw ChatStorageError.capacityExceeded } + retained[turn.id] = (snapshot.storageKey, turn) + } + } + } + } + + private static func isMessageID(_ value: String) -> Bool { + UUID(uuidString: value)?.uuidString.lowercased() == value + } + + private func commit(_ value: Envelope, scope: AccountScope) throws { + try requireOwner(scope) + try requireProtection() + try validateDrafts(value) + var next = value + next.version = 2 + let encodedImages = Mutex<[String: Data]>([:]) + let encoder = JSONEncoder() + encoder.dataEncodingStrategy = .custom { data, encoder in + // Freeze bytes before hashing; an attachment UUID does not authorize + // mutating an older snapshot's blob or aliasing another account. + let frozen = data.withUnsafeBytes { Data($0) } + let digest = ProtectedChatFiles.digest(frozen) + encodedImages.withLock { $0[digest] = frozen } + try Blob(sha256: digest, count: frozen.count).encode(to: encoder) + } + let data: Data + do { data = try encoder.encode(next) } catch { throw ChatStorageError.writeFailed } + guard data.count <= limits.metadataBytes else { throw ChatStorageError.capacityExceeded } + let images = encodedImages.withLock { $0 } + let account = ProtectedChatFiles.accountName(scope) + try files.withDirectory { directory in + try removeTemporaries(account: account, directory: directory) + try pruneImages(account: account, keeping: referencedImages, directory: directory) + for (digest, bytes) in images { + let name = account + "-" + digest + ".blob" + if let existing = try files.read(name, maximumBytes: limits.imageBytes, in: directory) { + guard existing == bytes else { throw ChatStorageError.corruptData } + } else { + try files.publish(bytes, name: name, account: account, directory: directory) { + try beforePublication(.image) + } + } + } + try files.publish(data, name: account + ".json", account: account, directory: directory) { + try beforePublication(.manifest) + } + envelope = next + referencedImages = Set(images.keys) + // A failed collection cannot undo a successful manifest publication. + // The next activation retries orphan collection; retirement requires + // complete cleanup and reports any failure. + try? pruneImages(account: account, keeping: Set(images.keys), directory: directory) + } + } + + private func removeTemporaries(account: String, directory: Int32) throws { + for name in try files.entries(in: directory) where ProtectedChatFiles.temporaryOwner(of: name) == account { + try files.remove(name, in: directory) + } + } + + private func pruneImages(account: String, keeping: Set, directory: Int32) throws { + for name in try files.entries(in: directory) + where ProtectedChatFiles.owner(of: name) == account && name.hasSuffix(".blob") { + let digest = String(name.dropFirst(65).dropLast(5)) + if !keeping.contains(digest) { try files.remove(name, in: directory) } + } + } + + private func finishCleanup() throws { + guard let account = pendingCleanup else { return } + try requireProtection() + do { + try files.withDirectory { directory in + for name in try files.entries(in: directory) + where ProtectedChatFiles.owner(of: name) == account + || ProtectedChatFiles.temporaryOwner(of: name) == account + { + try files.remove(name, in: directory) + } + } + } catch { throw ChatStorageError.cleanupRequired } + pendingCleanup = nil + } +} diff --git a/apps/maple-ios/Sources/MapleAppUI/Account/EntitlementFixtureView.swift b/apps/maple-ios/Sources/MapleAppUI/Account/EntitlementFixtureView.swift new file mode 100644 index 000000000..a73a4120b --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Account/EntitlementFixtureView.swift @@ -0,0 +1,260 @@ +#if os(iOS) && DEBUG + import Foundation + import MapleAppCore + import MapleSDK + import Observation + import SwiftUI + + /// Synthetic, in-memory entitlement fixture. Uses shipping account, plan, + /// model, editor and explanation views; it never constructs a network client. + struct EntitlementFixtureView: View { + @State private var model = EntitlementFixtureModel() + @State private var focused = false + @State private var media: MediaFeature? + @State private var showModels = false + @State private var showSettings = false + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Menu("Synthetic account state") { + ForEach(EntitlementScenario.allCases) { scenario in + Button(scenario.rawValue) { Task { await model.apply(scenario) } } + .accessibilityIdentifier("entitlement.scenario." + scenario.rawValue) + } + }.accessibilityIdentifier("entitlement.scenarios") + Button("Recover service without refreshing") { Task { await model.prepareRetryRecovery() } } + .accessibilityIdentifier("entitlement.prepare-retry") + Text(model.retryPrepared ? "service ready; account unchanged" : "not prepared") + .accessibilityIdentifier("entitlement.retry-service") + Text(model.billingLabel).accessibilityIdentifier("entitlement.billing") + Text(model.scenario.rawValue).accessibilityIdentifier("entitlement.scenario") + if model.account.chatAccess != .allowed { + AccountAccessNotice(account: model.account) + .accessibilityIdentifier("entitlement.notice") + } + ComposerEditor( + text: $model.draft.text, focused: $focused, ownerID: model.draft.id, + accountGeneration: model.account.scope.generation, + placeholder: "Synthetic unsent draft", accessibilityIdentifier: "entitlement.composer", + onSubmit: {} + ) + .frame(minHeight: 72).padding(8).background(.secondary.opacity(0.1)) + Button("Send") {} + .disabled(model.account.chatAccess != .allowed || model.draft.isEmpty) + .frame(minWidth: 44, minHeight: 44).accessibilityIdentifier("entitlement.send") + Button(model.account.selectedModel?.label ?? "Models") { showModels = true } + .frame(minWidth: 44, minHeight: 44).accessibilityIdentifier("entitlement.models") + Text(model.account.selectedModelID).accessibilityIdentifier("entitlement.selected-model") + ForEach([MediaFeature.images, .dictation, .speech, .webSearch]) { feature in + Button { + media = feature + } label: { + HStack { + Text(feature.title) + Spacer() + Image( + systemName: feature.access(in: model.account) == .allowed + ? "checkmark" : "info.circle") + } + } + .frame(minHeight: 44) + .accessibilityValue( + feature.access(in: model.account) == .allowed ? "available" : "unavailable" + ) + .accessibilityIdentifier("entitlement.media." + feature.rawValue) + } + Text(model.account.webSearchPreferred ? "Web preference on" : "Web preference off") + .accessibilityIdentifier("entitlement.web-preference") + Text(model.account.effectiveWebSearch ? "Effective web on" : "Effective web off") + .accessibilityIdentifier("entitlement.web-effective") + Button("Read-only account settings") { showSettings = true } + .frame(minHeight: 44).accessibilityIdentifier("entitlement.settings") + }.padding() + } + .navigationTitle("Synthetic account") + .sheet(item: $media) { MediaFeatureNotice(feature: $0, account: model.account) } + .sheet(isPresented: $showModels) { + NavigationStack { + SettingsModelPicker( + entitlements: model.account, + webSearchPreference: Binding( + get: { model.preferences.value.webSearchPreferred }, + set: { model.preferences.setWebSearchPreferred($0) }), + webSearchPreferences: model.preferences + ) { id in + model.preferences.setSelectedModelID(id); showModels = false + } + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { showModels = false }.accessibilityIdentifier( + "entitlement.models.close") + } + } + } + } + .sheet(isPresented: $showSettings) { + NavigationStack { + SettingsView( + preferences: model.preferences, entitlements: model.account, + account: SettingsAccount( + email: model.account.isGuest ? nil : "synthetic@example.test", isVerified: true, + isGuest: model.account.isGuest, canChangePassword: false), + version: "Synthetic fixture", onAction: { _ in } + ) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { showSettings = false }.accessibilityIdentifier( + "entitlement.settings.close") + } + } + } + } + } + .task { await model.preferences.load() } + } + } + + private enum EntitlementScenario: String, CaseIterable, Identifiable, Sendable { + case loading, failed, free, pro, pass, zaprite, seat, usage, unknownPlan + var id: String { rawValue } + } + + @MainActor @Observable private final class EntitlementFixtureModel { + let account: AccountEntitlements + let preferences: PreferencesController + let service: EntitlementFixtureService + var scenario: EntitlementScenario = .loading + var draft = ChatDraft(text: "Keep this unsent draft") + var retryPrepared = false + + init() { + let scope = AccountScope(apiOrigin: "http://127.0.0.1:1", principalID: "synthetic-entitlement") + let service = EntitlementFixtureService() + self.service = service + account = AccountEntitlements( + scope: scope, isGuest: ProcessInfo.processInfo.arguments.contains("--maple-entitlement-guest"), + billing: service, catalog: service) + preferences = PreferencesController(scope: scope, store: EntitlementFixturePreferences()) + preferences.onChange = { [weak account = self.account] value in + account?.webSearchPreferred = value.webSearchPreferred + account?.thinkingLevel = value.thinkingLevel + account?.selectModel(value.selectedModelID) + } + } + var billingLabel: String { + switch account.billing { + case .unknown: "unknown" + case .loading: "loading" + case .failed: "failed" + case let .loaded(status): + switch status.plan { + case .unknown: "unknownPlan" + case .free: "Free" + case .paid: "paid" + } + } + } + func prepareRetryRecovery() async { + // Only the synthetic backend recovers. The loaded account stays in + // its failure state until the shipping Retry control fetches again. + await service.configure(.pro) + retryPrepared = true + } + func apply(_ scenario: EntitlementScenario) async { + self.scenario = scenario; retryPrepared = false + await service.configure(scenario) + await account.refresh() + } + } + + private actor EntitlementFixtureService: BillingServing, ModelCatalogServing { + private var scenario: EntitlementScenario = .loading + private var waiters: [CheckedContinuation] = [] + func configure(_ scenario: EntitlementScenario) { + self.scenario = scenario + if scenario != .loading { + let pending = waiters; waiters.removeAll() + pending.forEach { $0.resume() } + } + } + func subscriptionStatus() async throws -> BillingStatus { + if scenario == .loading { await withCheckedContinuation { waiters.append($0) } } + if scenario == .failed { throw AccountServiceError.unavailable } + let plan = + scenario == .free ? "Free" : scenario == .unknownPlan ? "Unrecognized fixture plan" : "Pro Annual" + return try decode([ + "is_subscribed": scenario != .free, "product_id": "synthetic", + "product_name": plan, "subscription_status": "active", "can_chat": scenario != .usage, + "chats_remaining": scenario == .usage ? 0 : 50, + "current_period_end": 1_900_000_000, + "payment_provider": scenario == .pass + ? "subscription_pass" : scenario == .zaprite ? "zaprite" : "stripe", + // Acquisition fields are intentionally inert input to the shipping decoder. + "ios_iap_enabled": true, "ios_us_external_link_enabled": true, + ]) + } + func teamStatus() throws -> TeamStatus { + try decode([ + "has_team_subscription": scenario == .seat, "team_created": scenario == .seat, + "members_count": 3, "billed_seat_count": scenario == .seat ? 2 : 3, + ]) + } + func fetchCatalog() -> ModelCatalog { + let quickReasoning = ModelReasoning( + mandatory: false, defaultEnabled: true, supportedEfforts: ["high", "low"], defaultEffort: "low") + let lowReasoning = ModelReasoning( + mandatory: true, defaultEnabled: true, supportedEfforts: ["low"], defaultEffort: "low") + func item( + _ id: String, label: String, paid: Bool = false, media: Bool = true, enabled: Bool = true, + reasoning: ModelReasoning? = nil + ) + -> ModelCatalogItem + { + ModelCatalogItem( + id: id, object: "model", created: 0, ownedBy: "synthetic", displayName: label, + shortName: label, contextWindow: 32768, maxContextTokens: 32768, access: paid ? "pro" : "free", + capabilities: ModelCapabilities( + chat: true, vision: media, reasoning: reasoning != nil, toolUse: media), + badges: paid ? ["Pro", "Reasoning", "New"] : ["free"], + enabled: enabled, deprecated: false, reasoning: reasoning) + } + let capabilities = ModelCapabilities(chat: true, vision: false, reasoning: true, toolUse: false) + return ModelCatalog( + object: "list", + data: [ + item("synthetic-quick", label: "Quick target", reasoning: quickReasoning), + item("synthetic-power", label: "Power target", paid: true, reasoning: lowReasoning), + item("synthetic-text", label: "Text only", media: false), + item("synthetic-low", label: "Low thinking", media: false, reasoning: lowReasoning), + item("synthetic-disabled", label: "Disabled model", enabled: false), + ], + aliases: [ + ModelAlias( + id: .quick, label: "Quick", shortName: "Quick", description: "Synthetic quick model", + targetModel: "synthetic-quick", access: "free", + capabilities: capabilities, reasoning: quickReasoning), + ModelAlias( + id: .powerful, label: "Powerful", shortName: "Powerful", description: "Synthetic paid model", + targetModel: "synthetic-power", + access: "pro", capabilities: capabilities, reasoning: lowReasoning), + ], defaults: ModelDefaults(quick: .quick, powerful: .powerful), + audio: AudioModelCatalog( + transcription: AudioModelAvailability(available: true, model: "synthetic-transcription"), + speech: AudioModelAvailability(available: true, model: "synthetic-speech"))) + } + func retire() { configure(.failed) } + private func decode(_ value: [String: Any]) throws -> T { + let decoder = JSONDecoder(); decoder.keyDecodingStrategy = .convertFromSnakeCase + return try decoder.decode(T.self, from: JSONSerialization.data(withJSONObject: value)) + } + } + + private actor EntitlementFixturePreferences: PreferencesStoring { + private var value = LocalPreferences() + func activate(_ scope: AccountScope) -> LocalPreferences { value } + func save(_ value: LocalPreferences, scope: AccountScope, revision: UInt64) { self.value = value } + func retire(_ scope: AccountScope, removePersisted: Bool) {} + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Account/MediaFeatureNotice.swift b/apps/maple-ios/Sources/MapleAppUI/Account/MediaFeatureNotice.swift new file mode 100644 index 000000000..4d56e8249 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Account/MediaFeatureNotice.swift @@ -0,0 +1,137 @@ +#if os(iOS) + import MapleAppCore + import SwiftUI + + enum MediaFeature: String, Identifiable { + case images, dictation, speech, webSearch, model + var id: String { rawValue } + + var title: String { + switch self { + case .images: MapleStrings.text("Images") + case .dictation: MapleStrings.text("Dictation") + case .speech: MapleStrings.text("Read aloud") + case .webSearch: MapleStrings.text("Web search") + case .model: String(localized: "Powerful AI Models", table: "Settings", bundle: .module) + } + } + + @MainActor func access(in account: AccountEntitlements) -> AccountAccess { + switch self { + case .images: account.imageAccess + case .dictation: account.transcriptionAccess + case .speech: account.speechAccess + case .webSearch: account.webSearchAccess + case .model: .paidPlanRequired + } + } + } + + /// Explains current read-only entitlement and catalog state. It does not + /// change preferences, initiate purchases, or claim backend authorization. + struct MediaFeatureNotice: View { + let feature: MediaFeature + @Bindable var account: AccountEntitlements + var modelName: String? = nil + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Label(title, systemImage: "info.circle") + .font(.title2).accessibilityAddTraits(.isHeader) + Text(message).accessibilityIdentifier("media.access.message") + if feature == .model { + VStack(alignment: .leading, spacing: 12) { + Text( + "All models run in secure, encrypted environments", tableName: "Settings", + bundle: .module) + Text( + "Access to GLM 5.3, Kimi K2.6, and the full model lineup", tableName: "Settings", + bundle: .module) + Text( + "OpenAI GPT-OSS, Gemma, and other advanced models", tableName: "Settings", + bundle: .module) + Text( + "Higher token limits for longer conversations", tableName: "Settings", + bundle: .module) + Text( + "Priority access to new models as they launch", tableName: "Settings", + bundle: .module) + }.font(.subheadline) + } + }.frame(maxWidth: .infinity, alignment: .leading).padding(24) + } + .navigationTitle(title).navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(MapleStrings.text("Done")) { dismiss() } + .accessibilityIdentifier("media.access.close") + } + } + } + .presentationDetents([.medium, .large]).presentationDragIndicator(.visible) + } + + private var title: String { + guard feature == .model, let modelName else { return feature.title } + return String( + localized: "settings.model-access-title", defaultValue: "Access \(modelName)", + table: "Settings", bundle: .module) + } + + private var message: String { + if feature == .model { + return String( + localized: "Get access to our most advanced AI models for superior performance", + table: "Settings", bundle: .module) + } + return switch feature.access(in: account) { + case .allowed: MapleStrings.text("This feature is available for your account.") + case .unavailable: + if account.billing == .loading || account.catalog == .loading { + MapleStrings.text("Account details are still loading. Try again when loading finishes.") + } else { + MapleStrings.text("Account details are unavailable. Refresh account status and try again.") + } + case .unknownPlan: + MapleStrings.text("Your plan details are unavailable. Refresh account status and try again.") + case .freeGuest, .paidPlanRequired: paidPlanMessage + case .usageLimit: MapleStrings.text("Your account has reached its current usage limit.") + case .seatMismatch: + MapleStrings.text("Your team’s seat allocation needs attention. Contact your team administrator.") + case .modelUnavailable: + MapleStrings.text("The selected model is unavailable. Choose an available model.") + case .modelIncompatible: capabilityMessage + case .retired: MapleStrings.text("Your session has ended. Sign in again.") + } + } + + private var paidPlanMessage: String { + switch feature { + case .images: MapleStrings.text("Image attachments require an eligible paid plan.") + case .dictation: MapleStrings.text("Dictation requires an eligible paid plan.") + case .speech: MapleStrings.text("Read aloud requires an eligible paid plan.") + case .webSearch: MapleStrings.text("This feature is available for your account.") + case .model: + String( + localized: "Get access to our most advanced AI models for superior performance", + table: "Settings", bundle: .module) + } + } + + private var capabilityMessage: String { + switch feature { + case .images: MapleStrings.text("Choose a model that supports images.") + case .webSearch: MapleStrings.text("This feature is available for your account.") + case .dictation: MapleStrings.text("Dictation is currently unavailable. Try again later.") + case .speech: MapleStrings.text("Read aloud is currently unavailable. Try again later.") + case .model: + String( + localized: "Get access to our most advanced AI models for superior performance", + table: "Settings", bundle: .module) + } + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/AppEnvironment.swift b/apps/maple-ios/Sources/MapleAppUI/AppEnvironment.swift new file mode 100644 index 000000000..89872ac59 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/AppEnvironment.swift @@ -0,0 +1,952 @@ +#if os(iOS) + import Foundation + import MapleAppCore + import MapleSDK + import Network + import Observation + import UIKit + + @MainActor @Observable final class AppEnvironment { + let configuration: AppConfiguration + @ObservationIgnored private let accountLinkInbox: AccountLinkInbox + let client: MapleClient + let storage: ProtectedChatStore + let projectExpansionStore: LibraryProjectExpansionStore + let speechFiles: TemporarySpeechStore + let session: SessionController + let signIn: NativeSignInCoordinator + let preferenceStore = PreferencesStore() + private(set) var chats: [String: ChatRuntime] = [:] + private(set) var unreadConversationIDs: Set = [] + private(set) var selectedKey = ChatSnapshot.newChatKey { + didSet { + updatePresentationVisibility() + updateTitlePolling() + } + } + private(set) var accountAPI: ResearchAPI? + private(set) var entitlements: AccountEntitlements? + private(set) var deletion: ChatDeletionCoordinator? + private(set) var preferences: PreferencesController? + private(set) var projectExpansion: LibraryProjectExpansionController? + private(set) var library: LibraryModel? + private(set) var libraryContext: LibraryContext? + private(set) var readyScope: AccountScope? + private(set) var conversationDetails: [String: Conversation] = [:] + private var conversationDetailTickets: [String: UUID] = [:] + @ObservationIgnored private var titlePoll: Task? + private var titlePollConversationID: String? + private var titlePollTicket: UUID? + @ObservationIgnored private var itemPoll: Task? + private var itemPollRuntimeID: ObjectIdentifier? + private var itemPollConversationID: String? + private var itemPollTicket: UUID? + var sidebarVisible = false { didSet { updateRecentsPolling() } } + var accountFlow: AccountFlow? { didSet { updatePresentationVisibility() } } + var menuPresented = false { + didSet { + if menuPresented { dictation?.stopCaptureForNavigation() } + updatePresentationVisibility() + } + } + var settingsPresented = false { didSet { updatePresentationVisibility() } } + private(set) var listening: ReplyListeningSession? + private(set) var dictation: DictationCoordinator? + private let imageProcessor = ImageImportProcessor() + private let imageBudget = ImageImportBudget() + private(set) var imageImporters: [ObjectIdentifier: ImageImportController] = [:] + private var audioRetirements: [ReplyListeningSession] = [] + private var dictationSend = DictationSendAssociation() + private var pendingAccountFlow: AccountFlow? + private var presentationBlocks: Set = [] + var chatNavigationBlocked: Bool { + !foreground || accountFlow != nil || settingsPresented || !presentationBlocks.isEmpty + } + private var projectSelections: [UUID: String] = [:] + private(set) var isOnline = true + private(set) var issue: ChatIssue? + private var retainedSnapshots: [ChatSnapshot] = [] + private var navigation = UUID() + private var activatingScope: AccountScope? + private(set) var activationFailedScope: AccountScope? + private var accountCeremonies: [AuthCeremonies] = [] + var foreground = true { + didSet { + if !foreground { + dictation?.stopCaptureForNavigation() + let projectExpansion = self.projectExpansion + Task { await projectExpansion?.flush() } + } + for runtime in chats.values { runtime.isForeground = foreground } + updatePresentationVisibility() + updateTitlePolling() + if foreground && !oldValue { reconcileAfterForeground() } + } + } + #if DEBUG + private(set) var lifecycleFixture: LocalLifecycleFixtureDriver? + #endif + @ObservationIgnored private let network = NWPathMonitor() + + init(configuration: AppConfiguration, accountLinkInbox: AccountLinkInbox = AccountLinkInbox()) throws { + self.configuration = configuration + self.accountLinkInbox = accountLinkInbox + client = try MapleClient( + apiURL: configuration.apiURL.absoluteString, clientID: configuration.clientID, + policy: configuration.trustPolicy, + store: KeychainSecureStore(service: "cloud.opensecret.maple.native.dev.sdk")) + let support = try FileManager.default.url( + for: .applicationSupportDirectory, in: .userDomainMask, + appropriateFor: nil, create: true) + storage = ProtectedChatStore( + root: support.appendingPathComponent("ProtectedDrafts", isDirectory: true), + protectionAvailable: UIApplication.shared.isProtectedDataAvailable) + projectExpansionStore = LibraryProjectExpansionStore( + root: support.appendingPathComponent("LibraryPresentation", isDirectory: true), + protectionAvailable: UIApplication.shared.isProtectedDataAvailable) + speechFiles = TemporarySpeechStore( + root: support.appendingPathComponent("TemporarySpeech", isDirectory: true)) + session = SessionController( + service: SDKSessionService(client: client), + storage: NativeAccountDataLifecycle( + drafts: storage, speech: speechFiles, projectExpansion: projectExpansionStore)) + signIn = NativeSignInCoordinator( + session: session, + ceremonies: AuthCeremonies( + service: SDKAuthCeremonyService(client: client), configuration: configuration)) + session.onCancelExternal = { [weak self] in + self?.accountLinkInbox.retire() + await self?.signIn.cancelPresentation() + } + session.onRetire = { [weak self] scope in + guard let self else { return } + self.cancelTitlePolling() + self.cancelItemPolling() + for ceremony in self.accountCeremonies { ceremony.invalidate() } + self.accountCeremonies.removeAll() + self.accountFlow?.ceremonies.invalidate(); self.accountFlow = nil + self.pendingAccountFlow?.ceremonies.invalidate(); self.pendingAccountFlow = nil + self.presentationBlocks.removeAll() + self.menuPresented = false; self.settingsPresented = false; self.projectSelections = [:] + self.deletion?.invalidate(); self.deletion = nil + self.libraryContext?.invalidate(); self.libraryContext = nil; self.library = nil + self.readyScope = nil + let projectExpansion = self.projectExpansion; self.projectExpansion = nil + projectExpansion?.invalidate() + self.conversationDetails = [:] + self.conversationDetailTickets = [:] + self.dictation?.invalidate(); self.dictation = nil + self.dictationSend.reset() + #if DEBUG + self.lifecycleFixture?.listeningProof?.retire() + #endif + for importer in self.imageImporters.values { importer.invalidate() } + self.imageImporters.removeAll() + for runtime in self.chats.values where runtime.scope == scope { runtime.invalidate() } + self.chats.removeAll(); self.accountAPI = nil; self.selectedKey = ChatSnapshot.newChatKey + self.unreadConversationIDs.removeAll() + self.retainedSnapshots = []; self.navigation = UUID() + self.activatingScope = nil + self.activationFailedScope = nil + let details = self.entitlements; self.entitlements = nil + let preferences = self.preferences; self.preferences = nil + if let listening = self.listening { self.audioRetirements.append(listening) } + self.listening = nil + var audioFailed = false + for listening in self.audioRetirements { + do { + try await listening.invalidate() + self.audioRetirements.removeAll { $0 === listening } + } catch { audioFailed = true } + } + try? await preferences?.retire() + try? await projectExpansion?.retire() + await details?.retire() + if audioFailed { throw ChatStorageError.cleanupRequired } + } + #if DEBUG + if configuration.profile == .local, + (ProcessInfo.processInfo.arguments.contains("--maple-local-lifecycle-fixture") + || ProcessInfo.processInfo.arguments.contains("--maple-local-listening-fixture")) + { + let fixture = LocalLifecycleFixtureDriver() + lifecycleFixture = fixture + fixture.onConnectivity = { [weak self] online in self?.applyConnectivity(online) } + } + #endif + network.pathUpdateHandler = { [weak self] path in + let online = path.status == .satisfied + Task { @MainActor [weak self] in + guard let self else { return } + #if DEBUG + guard self.lifecycleFixture == nil else { return } + #endif + self.applyConnectivity(online) + } + } + network.start(queue: DispatchQueue(label: "cloud.opensecret.maple.connectivity")) + } + + private func applyConnectivity(_ online: Bool) { + isOnline = online + for runtime in chats.values { runtime.isOnline = online } + } + + var selectedChat: ChatRuntime? { chats[selectedKey] } + + func activity(for id: String) -> ConversationActivity { + guard let scope = session.account?.scope, deletion?.isConversationDeleted(id) != true else { + return .idle + } + if chats.values.contains(where: { + $0.scope == scope && !$0.isRetired && $0.conversationID == id && $0.hasUnresolvedRun + }) { + return .running + } + return unreadConversationIDs.contains(id) ? .unread : .idle + } + + func activity(forProject id: String) -> ConversationActivity { + guard let scope = session.account?.scope else { return .idle } + let runtimes = chats.values.filter { + guard $0.scope == scope, !$0.isRetired else { return false } + let project: String? + if $0.conversationID == nil, let run = $0.snapshot.pendingRun { + project = run.options.projectID + } else { + project = projectID(for: $0) + } + return project == id + } + if runtimes.contains(where: { + $0.hasUnresolvedRun && ($0.conversationID.map { deletion?.isConversationDeleted($0) != true } ?? true) + }) { + return .running + } + return runtimes.contains(where: { $0.conversationID.map { activity(for: $0) == .unread } ?? false }) + ? .unread : .idle + } + + func activate(_ account: AccountSession) async { + guard session.phase == .signedIn, session.account?.scope == account.scope, + accountAPI == nil, activatingScope != account.scope + else { return } + activatingScope = account.scope + activationFailedScope = nil + issue = nil + defer { if activatingScope == account.scope { activatingScope = nil } } + do { + let api = try await client.accountAPI() + guard session.account?.scope == account.scope else { return } + let retained = try await storage.loadAll(scope: account.scope) + guard session.account?.scope == account.scope else { return } + retainedSnapshots = retained + accountAPI = api + let coordinator = ChatDeletionCoordinator( + scope: account.scope, retained: retained, + service: SDKChatService(api: api), persistence: storage) + deletion = coordinator + coordinator.onRemoved = { [weak self] removed in + guard let self, self.session.account?.scope == account.scope else { return } + let wasSelected = self.selectedChat === removed + self.imageImporters.removeValue(forKey: ObjectIdentifier(removed))?.invalidate() + for key in Array(self.chats.keys) where self.chats[key] === removed { + self.chats.removeValue(forKey: key) + } + if wasSelected { + self.selectedKey = ChatSnapshot.newChatKey + Task { await self.selectChat(nil) } + } + } + let preferences = PreferencesController(scope: account.scope, store: preferenceStore) + self.preferences = preferences + await preferences.load() + guard session.account?.scope == account.scope else { return } + let projectExpansion = LibraryProjectExpansionController( + scope: account.scope, store: projectExpansionStore) + self.projectExpansion = projectExpansion + await projectExpansion.load() + guard session.account?.scope == account.scope else { return } + let details = AccountEntitlements( + scope: account.scope, isGuest: account.user.loginMethod == "guest", + billing: BillingService( + scope: account.scope, configuration: configuration, + tokenSource: SDKBillingTokenSource(api: api)), + catalog: SDKModelCatalogSource(api: api), + selectedModelID: preferences.value.selectedModelID, + webSearchPreferred: preferences.value.webSearchPreferred, + thinkingLevel: preferences.value.thinkingLevel) + entitlements = details + preferences.onChange = { [weak self, weak details] value in + guard self?.session.account?.scope == account.scope else { return } + details?.webSearchPreferred = value.webSearchPreferred + details?.thinkingLevel = value.thinkingLevel + details?.selectModel(value.selectedModelID) + self?.listening?.updatePreferences(voice: value.voice, speed: value.playbackSpeed) + } + let admission: @MainActor @Sendable () -> SpeechIssue? = { [weak self, weak details] in + guard self?.session.account?.scope == account.scope else { return .retired } + switch details?.speechAccess { + case .allowed: return nil + case .paidPlanRequired, .freeGuest: return .paidPlanRequired + case .usageLimit: return .usageLimit + case .retired: return .retired + default: return .unavailable + } + } + let player = NativeSpeechPlayer() + let preparer = SpeechPreparer( + synthesis: SDKSpeechSynthesizer(api: api), files: speechFiles, + inspector: NativeSpeechInspector(), admission: { await admission() }) + let listening = ReplyListeningSession( + scope: account.scope, preparer: preparer, player: player, + voice: preferences.value.voice, speed: preferences.value.playbackSpeed, admission: admission) + player.bind(to: listening) + listening.onSpeedChanged = { [weak preferences] speed in preferences?.setPlaybackSpeed(speed) } + self.listening = listening + #if DEBUG + if ProcessInfo.processInfo.arguments.contains("--maple-local-listening-fixture") { + lifecycleFixture?.listeningProof = LocalListeningProof(scope: account.scope, session: listening) + } + #endif + let capture: any DictationCapturing + let transcription: any DictationTranscribing + #if DEBUG + if let fixture = lifecycleFixture { + capture = fixture.capture; transcription = fixture.transcriber + } else { + capture = AVDictationCapture(); transcription = SDKDictationService(api: api) + } + #else + capture = AVDictationCapture(); transcription = SDKDictationService(api: api) + #endif + let dictation = DictationCoordinator( + scope: account.scope, capture: capture, transcriber: transcription) + dictation.onCaptureAdmitted = { [weak self] owner in + guard self?.session.account?.scope == owner.scope else { return } + self?.dictationSend.captureAdmitted(owner) + } + dictation.onActivityChange = { [weak self, weak listening] activity in + guard self?.session.account?.scope == account.scope else { return } + switch activity { + case .capturing: + listening?.startDictation() + case .transcribing: listening?.transcriptionStarted() + case .idle: listening?.dictationFinishedOrCancelled() + } + } + self.dictation = dictation + let context = LibraryContext( + scope: account.scope, api: api, deletions: coordinator, + projectExpansion: projectExpansion) + self.libraryContext = context; self.library = context.root + context.onConversationsDeleted = { [weak self] ids, all in + guard let self, self.session.account?.scope == account.scope else { return } + if all { self.unreadConversationIDs.removeAll() } else { self.unreadConversationIDs.subtract(ids) } + self.retainedSnapshots.removeAll { all || $0.conversationID.map(ids.contains) == true } + self.conversationDetails = self.conversationDetails.filter { !all && !ids.contains($0.key) } + self.conversationDetailTickets = self.conversationDetailTickets.filter { + !all && !ids.contains($0.key) + } + } + context.onConversationUpdated = { [weak self] conversation in + guard let self, self.session.account?.scope == account.scope else { return } + self.conversationDetails[conversation.id] = conversation + self.conversationDetailTickets[conversation.id] = UUID() + self.updateTitlePolling() + } + context.onConversationProjectUpdated = { [weak self] ids, projectID in + guard let self, self.session.account?.scope == account.scope else { return } + for id in ids { + self.conversationDetailTickets[id] = UUID() + self.conversationDetails[id]?.projectId = projectID + } + } + context.onProjectDeleted = { [weak self] id in + guard let self, self.session.account?.scope == account.scope else { return } + self.projectSelections = self.projectSelections.filter { $0.value != id } + } + for snapshot in retained + where snapshot.conversationID == nil && snapshot.storageKey.hasPrefix("pending:") { + let runtime = makeRuntime(account: account, api: api, id: nil) + if runtime.loadRecovered(snapshot) { chats[snapshot.storageKey] = runtime } + } + // Publish the interactive scene only after its first menu pages + // and account presentation are ready. Later refreshes retain + // these account-owned snapshots rather than rebuilding the menu. + async let menuReady: Void = context.prepareMenu() + async let detailsReady: Void = details.refresh() + await selectChat(nil) + _ = await (menuReady, detailsReady) + guard session.account?.scope == account.scope, libraryContext === context, + !Task.isCancelled + else { return } + readyScope = account.scope + } catch { + guard session.account?.scope == account.scope else { return } + issue = .signedOut + activationFailedScope = account.scope + } + } + + @discardableResult + func selectChat( + _ id: String?, selectionTicket ticket: UUID = UUID(), + onSelectionStaged: (@MainActor () -> Void)? = nil + ) async -> Bool { + dictation?.stopCaptureForNavigation() + guard let account = session.account, let api = accountAPI else { return false } + if let id, deletion?.isConversationDeleted(id) == true { + return await selectChat(nil, selectionTicket: ticket, onSelectionStaged: onSelectionStaged) + } + navigation = ticket + let previous = selectedChat + let key = id ?? ChatSnapshot.newChatKey + if id == nil, let prior = chats[key], prior.snapshot.storageKey != key || prior.hasUnresolvedRun { + guard await prior.prepareForNewChat(), navigation == ticket, + session.account?.scope == account.scope + else { return false } + if chats[key] === prior { chats.removeValue(forKey: key) } + chats[prior.snapshot.storageKey] = prior + } + previous?.isVisible = false + selectedKey = key + if let id { unreadConversationIDs.remove(id) } + if let existing = chats[key] { + existing.isVisible = true + updatePresentationVisibility() + onSelectionStaged?() + guard session.account?.scope == account.scope, navigation == ticket, selectedChat === existing else { + return false + } + await existing.reconcileOnLifecycle(foreground: false) + guard session.account?.scope == account.scope, navigation == ticket else { return false } + if let id { await refreshConversationDetails(id, account: account, api: api) } + return session.account?.scope == account.scope && navigation == ticket && selectedChat === existing + } + let runtime = makeRuntime(account: account, api: api, id: id) + runtime.isVisible = true + chats[key] = runtime + updatePresentationVisibility() + onSelectionStaged?() + guard session.account?.scope == account.scope, navigation == ticket, selectedChat === runtime else { + return false + } + await runtime.load(chatID: id) + if let id { await refreshConversationDetails(id, account: account, api: api) } + return session.account?.scope == account.scope && navigation == ticket && selectedChat === runtime + } + + var selectedProjectID: String? { + get { + guard let runtime = selectedChat else { return nil } + return projectID(for: runtime) + } + set { + guard let runtime = selectedChat, runtime.conversationID == nil else { return } + projectSelections[runtime.snapshot.storageOwner] = newValue + } + } + + func projectID(for runtime: ChatRuntime) -> String? { + guard session.account?.scope == runtime.scope, !runtime.isRetired else { return nil } + if let id = runtime.conversationID { + if let details = conversationDetails[id] { return details.projectId } + if let context = libraryContext, context.scope == runtime.scope { + let cached = + [context.root, context.menu.pinned, context.menu.recent] + + Array(context.menu.projects.values) + for model in cached { + if let conversation = model.conversations.first(where: { $0.id == id }) { + return conversation.projectId + } + } + } + } + if let run = runtime.snapshot.pendingRun { return run.options.projectID } + return projectSelections[runtime.snapshot.storageOwner] + } + + @discardableResult + func selectProject(runtime: ChatRuntime, id: String?) async -> Bool { + guard let scope = session.account?.scope, runtime.scope == scope, + selectedChat === runtime, !runtime.isRetired, + let context = libraryContext, context.scope == scope + else { return false } + let owner = runtime.snapshot.storageOwner + if let conversationID = runtime.conversationID { + guard deletion?.isConversationDeleted(conversationID) != true else { return false } + guard await context.root.moveConversations(ids: [conversationID], projectID: id) else { return false } + guard session.account?.scope == scope, libraryContext === context, + selectedChat === runtime, !runtime.isRetired, + runtime.snapshot.storageOwner == owner, runtime.conversationID == conversationID, + deletion?.isConversationDeleted(conversationID) != true, !Task.isCancelled + else { return false } + // The context callback updates every menu snapshot. A metadata + // fetch may still be absent after offline navigation, so keep + // this runtime's acknowledged destination available as well. + projectSelections[owner] = id + conversationDetailTickets[conversationID] = UUID() + conversationDetails[conversationID]?.projectId = id + return true + } + // Draft assignment has no suspension and belongs to this exact + // runtime, independently of another chat's project selection. + projectSelections[owner] = id + return true + } + + func selectNewChat(projectID: String? = nil) async { + guard let scope = session.account?.scope else { return } + await NewChatProjectSelection.perform( + scope: scope, projectID: projectID, + navigate: { ticket in await self.selectChat(nil, selectionTicket: ticket) }, + current: { + .init( + accountScope: self.session.account?.scope, navigation: self.navigation, + selectedKey: self.selectedKey, runtime: self.selectedChat) + }, + assign: { runtime, project in self.projectSelections[runtime.snapshot.storageOwner] = project }) + } + + private func refreshConversationDetails(_ id: String, account: AccountSession, api: ResearchAPI) async { + guard session.account?.scope == account.scope, deletion?.isConversationDeleted(id) != true else { return } + let ticket = UUID() + conversationDetailTickets[id] = ticket + do { + let conversation = try await api.getConversation(id: id) + guard session.account?.scope == account.scope, conversation.id == id, + deletion?.isConversationDeleted(id) != true, conversationDetailTickets[id] == ticket, + !Task.isCancelled + else { return } + let changed = conversationDetails[id] != conversation + conversationDetails[id] = conversation + deletion?.updateProject(conversationID: id, projectID: conversation.projectId) + if changed, let context = libraryContext, context.scope == account.scope { + context.receiveConversationDetails(conversation) + } + updateTitlePolling() + } catch { /* History and drafts remain available when metadata cannot refresh. */ } + } + + private func cancelTitlePolling() { + titlePoll?.cancel() + titlePoll = nil + titlePollConversationID = nil + titlePollTicket = nil + } + + private func cancelItemPolling() { + itemPoll?.cancel() + itemPoll = nil + itemPollRuntimeID = nil + itemPollConversationID = nil + itemPollTicket = nil + } + + /// The selected projection owns polling, independently of the account-owned run. + private func updateItemPolling() { + guard foreground, let account = session.account, let runtime = selectedChat, + runtime.scope == account.scope, runtime.isVisible, !runtime.isRetired, + runtime.deletionFenceIDs.isEmpty, let id = runtime.conversationID, + deletion?.isConversationDeleted(id) != true + else { + cancelItemPolling() + return + } + let owner = ObjectIdentifier(runtime) + guard itemPollRuntimeID != owner || itemPollConversationID != id || itemPoll == nil else { return } + cancelItemPolling() + let ticket = UUID() + itemPollRuntimeID = owner + itemPollConversationID = id + itemPollTicket = ticket + itemPoll = Task { [weak self, weak runtime] in + defer { + if self?.itemPollTicket == ticket { self?.cancelItemPolling() } + } + await ConversationItemPolling.run( + shouldContinue: { [weak self, weak runtime] in + guard let self, let runtime else { return false } + return self.itemPollTicket == ticket && self.foreground + && self.session.account?.scope == account.scope + && self.selectedChat === runtime && runtime.conversationID == id + && runtime.isVisible && !runtime.isRetired && runtime.deletionFenceIDs.isEmpty + && self.deletion?.isConversationDeleted(id) != true + }, + poll: { [weak runtime] in await runtime?.pollNewItems() }) + } + } + + private func updateRecentsPolling() { + libraryContext?.menu.updateRecentsPolling( + visible: (menuPresented || sidebarVisible) && !settingsPresented && accountFlow == nil + && presentationBlocks.isEmpty, + foreground: foreground) + } + + /// Title generation outlives the response request. Keep checking only + /// the selected conversation, with the same 0.5→10 second backoff as Research. + private func updateTitlePolling() { + guard foreground, let account = session.account, let api = accountAPI, + let id = selectedChat?.conversationID, + deletion?.isConversationDeleted(id) != true, + conversationDetails[id]?.metadata?["title"] == .string("New Conversation") + else { + cancelTitlePolling() + return + } + guard titlePollConversationID != id || titlePoll == nil else { return } + cancelTitlePolling() + let ticket = UUID() + titlePollConversationID = id + titlePollTicket = ticket + titlePoll = Task { [weak self] in + await ConversationTitlePolling.run( + shouldContinue: { [weak self] in + guard let self else { return false } + return self.titlePollTicket == ticket && self.foreground + && self.session.account?.scope == account.scope + && self.selectedChat?.conversationID == id + && self.deletion?.isConversationDeleted(id) != true + && self.conversationDetails[id]?.metadata?["title"] == .string("New Conversation") + }, + refresh: { [weak self] in + await self?.refreshConversationDetails(id, account: account, api: api) + }) + } + } + + var unfinishedChats: [ChatRuntime] { + chats.values.filter { $0.snapshot.storageKey.hasPrefix("pending:") && $0.conversationID == nil } + .sorted { $0.snapshot.storageKey < $1.snapshot.storageKey } + } + + func selectUnfinished(_ runtime: ChatRuntime) { + guard session.account?.scope == runtime.scope else { return } + dictation?.stopCaptureForNavigation() + navigation = UUID(); selectedChat?.isVisible = false + selectedKey = runtime.snapshot.storageKey; runtime.isVisible = true + Task { await runtime.reconcileOnLifecycle(foreground: false) } + } + + private func reconcileAfterForeground() { + guard let scope = session.account?.scope else { return } + let owners = Array(chats.values) + Task { [weak self] in + for runtime in owners { + guard let self, self.foreground, self.session.account?.scope == scope else { return } + guard runtime.scope == scope, self.chats.values.contains(where: { $0 === runtime }) else { + continue + } + await runtime.reconcileOnLifecycle(foreground: true) + } + } + } + + private func makeRuntime(account: AccountSession, api: ResearchAPI, id: String?) -> ChatRuntime { + let service: any ChatServing + #if DEBUG + if let proof = lifecycleFixture?.listeningProof, proof.scope == account.scope { + service = LocalListeningChatService(base: SDKChatService(api: api), gate: proof.requestGate) + } else { + service = SDKChatService(api: api) + } + #else + service = SDKChatService(api: api) + #endif + let candidate = ChatRuntime( + scope: account.scope, service: service, persistence: storage, + conversationID: id) + let runtime = deletion?.register(candidate) ?? candidate + runtime.onDeletionFenceChanged = { [weak self, weak runtime] in + guard let self, let runtime, self.session.account?.scope == runtime.scope, + self.selectedChat === runtime + else { return } + self.updateItemPolling() + } + if imageImporters[ObjectIdentifier(runtime)] == nil { + imageImporters[ObjectIdentifier(runtime)] = ImageImportController( + runtime: runtime, processor: imageProcessor, budget: imageBudget, + capacityIssue: { [weak self, weak runtime] proposed in + guard let self, let runtime, self.session.account?.scope == account.scope else { + return .retired + } + let loadedOwners = Set(self.chats.values.map { $0.snapshot.storageOwner }) + let retained = self.retainedSnapshots.filter { !loadedOwners.contains($0.storageOwner) } + let others = self.chats.values.filter { $0 !== runtime }.map(\.snapshot) + let usage = ChatResourceUsage(snapshots: retained + others + [proposed]) + if usage.images > 640 { return .accountImageLimit } + if usage.imageBytes > 256 * 1_024 * 1_024 { return .accountAttachmentLimit } + return nil + }, + admission: { [weak self] in + self?.session.account?.scope == account.scope && self?.entitlements?.imageAccess == .allowed + }) + } + runtime.onDraftOwnerChanging = { [weak self, weak runtime] owner in + guard let runtime else { return } + self?.dictation?.ownerWillChange(runtime: runtime, draftID: owner) + } + runtime.onRunStarted = { [weak self, weak runtime] in + guard let self, let runtime, self.session.account?.scope == runtime.scope, + !runtime.isRetired, let id = runtime.conversationID + else { return } + self.unreadConversationIDs.remove(id) + } + runtime.onDraftOwnerRetired = { [weak self, weak runtime] owner in + guard let runtime else { return } + self?.dictation?.ownerRetired(runtime: runtime, draftID: owner) + self?.dictationSend.retire( + DictationDraftOwner( + scope: runtime.scope, storageOwnerID: runtime.snapshot.storageOwner, draftID: owner)) + self?.imageImporters[ObjectIdentifier(runtime)]?.cancel(draftID: owner) + } + runtime.onQuiesceForDeletion = { [weak self, weak runtime] in + guard let runtime else { return } + if self?.selectedChat === runtime { self?.cancelItemPolling() } + for draft in runtime.snapshot.ownedDrafts { + self?.dictation?.ownerRetired(runtime: runtime, draftID: draft.id) + self?.dictationSend.retire( + DictationDraftOwner( + scope: runtime.scope, storageOwnerID: runtime.snapshot.storageOwner, draftID: draft.id)) + self?.imageImporters[ObjectIdentifier(runtime)]?.cancel(draftID: draft.id) + } + } + runtime.isOnline = isOnline; runtime.isForeground = foreground + runtime.onConversationAssigned = { [weak self, weak runtime] assignedID in + guard let self, let runtime, self.session.account?.scope == runtime.scope else { return } + let wasSelected = self.selectedChat === runtime + for key in Array(self.chats.keys) where self.chats[key] === runtime { + self.chats.removeValue(forKey: key) + } + self.chats[assignedID] = runtime + if wasSelected { self.selectedKey = assignedID } + let arm = self.dictationSend.conversationAssigned( + scope: runtime.scope, storageOwnerID: runtime.snapshot.storageOwner, chatID: assignedID, + reviewActive: self.listening?.dictationHold == .review) + if let arm { self.listening?.sentAfterDictation(chatID: arm.chatID, turnID: arm.messageID) } + #if DEBUG + self.lifecycleFixture?.listeningProof?.assigned( + scope: runtime.scope, chatID: assignedID, armedMessageID: arm?.messageID) + #endif + Task { + guard self.session.account?.scope == account.scope else { return } + await self.refreshConversationDetails(assignedID, account: account, api: api) + guard self.session.account?.scope == account.scope else { return } + await self.libraryContext?.menu.refreshRecents() + } + } + runtime.onTurnAccepted = { [weak self] accepted in + guard let self, self.session.account?.scope == accepted.scope else { return } + let arm = self.dictationSend.accepted( + accepted, reviewActive: self.listening?.dictationHold == .review) + if let arm { self.listening?.sentAfterDictation(chatID: arm.chatID, turnID: arm.messageID) } + #if DEBUG + self.lifecycleFixture?.listeningProof?.accepted(accepted, armedMessageID: arm?.messageID) + #endif + } + runtime.onCompletion = { [weak self] completion in + guard let self, self.session.account?.scope == completion.scope else { return } + self.listening?.completed(completion) + #if DEBUG + self.lifecycleFixture?.listeningProof?.completed(completion) + #endif + } + runtime.onSuccessfulVisibleCompletion = { [weak self] _ in + guard let self, self.session.account?.scope == account.scope else { return } + UINotificationFeedbackGenerator().notificationOccurred(.success) + } + runtime.onSuccessfulCompletion = { [weak self, weak runtime] chatID in + guard let self, let runtime, self.session.account?.scope == account.scope, !runtime.isRetired else { + return + } + if runtime.isVisible && self.foreground { + self.unreadConversationIDs.remove(chatID) + } else { + self.unreadConversationIDs.insert(chatID) + } + let details = self.entitlements + Task { + guard self.session.account?.scope == account.scope else { return } + await details?.refreshBilling() + await self.refreshConversationDetails(chatID, account: account, api: api) + } + } + runtime.canAdmitSnapshot = { [weak self, weak runtime] proposed in + guard let self, let runtime, self.session.account?.scope == runtime.scope else { return false } + let loadedOwners = Set(self.chats.values.map { $0.snapshot.storageOwner }) + let retained = self.retainedSnapshots.filter { !loadedOwners.contains($0.storageOwner) } + let others = self.chats.values.filter { $0 !== runtime }.map(\.snapshot) + return ChatResourceUsage(snapshots: retained + others + [proposed]).fitsAccount + } + return runtime + } + + /// A browser presentation belongs to its originating account and view. Late + /// dismissal from a retired account cannot expose a replacement chat. + func setPresentationBlocked(_ blocked: Bool, id: UUID, scope: AccountScope) { + guard session.account?.scope == scope, + presentationBlocks.contains(id) != blocked + else { return } + if blocked { + presentationBlocks.insert(id); dictation?.stopCaptureForNavigation() + } else { + presentationBlocks.remove(id) + } + updatePresentationVisibility() + } + + func updatePresentationVisibility() { + let visible = + session.account != nil && !menuPresented && !settingsPresented && accountFlow == nil + && presentationBlocks.isEmpty + let selected = selectedChat + for runtime in chats.values { + let isVisible = visible && runtime === selected + if runtime.isVisible != isVisible { runtime.isVisible = isVisible } + } + listening?.setVisibility(chatID: visible ? selectedChat?.conversationID : nil, foreground: foreground) + if visible, foreground, let id = selected?.conversationID { unreadConversationIDs.remove(id) } + updateItemPolling() + updateRecentsPolling() + } + + func send(_ runtime: ChatRuntime, options: ChatSendOptions) -> Bool { + guard session.account?.scope == runtime.scope else { return false } + let owner = DictationDraftOwner( + scope: runtime.scope, storageOwnerID: runtime.snapshot.storageOwner, draftID: runtime.snapshot.draft.id) + let ticket = dictationSend.ticket(for: owner, reviewActive: listening?.dictationHold == .review) + guard runtime.send(options: options) else { return false } + if let id = runtime.conversationID { unreadConversationIDs.remove(id) } + if let ticket { dictationSend.didSubmit(ticket) } + return true + } + + func readAloud(_ item: ConversationItem, runtime: ChatRuntime) { + guard session.account?.scope == runtime.scope, let chatID = runtime.conversationID, + item.role == "assistant", entitlements?.speechAvailable == true + else { return } + let text = (item.content ?? []).compactMap { part in + ["output_text", "text"].contains(part.type) ? part.text : nil + }.joined(separator: "\n") + listening?.playReply(SpeechReply(chatID: chatID, responseID: item.responseId ?? item.id, text: text)) + } + + func makeCeremony() -> AuthCeremonies { + let ceremony = AuthCeremonies( + service: SDKAuthCeremonyService(client: client), configuration: configuration, + scope: session.account?.scope, accountEmail: session.account?.user.email) + if let scope = session.account?.scope { + let preferences = self.preferences + let projectExpansion = self.projectExpansion + for previous in accountCeremonies { previous.invalidate() } + accountCeremonies.removeAll() + accountCeremonies.append(ceremony) + ceremony.onPasswordResetResult = { [weak self] resetScope in + guard resetScope == scope, self?.session.account?.scope == scope else { return } + await self?.session.signOut() + } + guard let coordinator = deletion else { return ceremony } + var lease: ChatDeletionLease? + ceremony.beforeAccountDeletion = { [weak self] requested in + guard requested == scope, self?.session.account?.scope == scope else { + throw AuthCeremonyIssue.changedAccount + } + let owned = try coordinator.begin(.all); lease = owned + do { + try await coordinator.quiesce(owned) + try await projectExpansion?.prepareAccountDeletion() + } catch { + coordinator.release(owned); lease = nil; throw error + } + } + ceremony.onAccountDeletionResult = { [weak self] requested, deleted in + guard requested == scope else { return } + if let owned = lease { + lease = nil + if deleted { try? await coordinator.finish(owned, deleteScope: true) } + coordinator.release(owned) + } + // The SDK already revoked confirmed-deleted authority. The + // session owns retryable file cleanup, including late success. + if deleted { + try? await projectExpansion?.retire(removePersisted: true) + } else { + try? await projectExpansion?.cancelAccountDeletion() + } + if deleted, self?.session.account?.scope == scope { + try? await preferences?.retire(removePersisted: true) + await self?.session.signOut() + } + } + } + return ceremony + } + + func protectedDataChanged(_ available: Bool) async { + if !available { dictation?.stopCaptureForNavigation() } + await storage.setProtectedDataAvailable(available) + await projectExpansionStore.setProtectedDataAvailable(available) + if available { + try? await projectExpansionStore.startupCleanup() + await projectExpansion?.load() + projectExpansion?.retrySave() + for runtime in chats.values { await runtime.saveDraft() } + } + } + + func presentAccount(_ screen: AccountScreen, code: String? = nil) { + guard !signIn.isRunning, session.phase == .signedIn || session.phase == .signedOut else { return } + if let accountFlow, accountFlow.screen == screen { + if let code { accountFlow.code = code } + return + } + if let pendingAccountFlow, pendingAccountFlow.screen == screen { + if let code { pendingAccountFlow.code = code } + return + } + accountFlow?.ceremonies.invalidate(); pendingAccountFlow?.ceremonies.invalidate() + let flow = AccountFlow(screen: screen, ceremonies: makeCeremony(), code: code) + if menuPresented || settingsPresented { + pendingAccountFlow = flow + menuPresented = false; settingsPresented = false + } else { + accountFlow = flow + } + } + + func finishNavigationDismissal() { + guard !menuPresented, !settingsPresented, let flow = pendingAccountFlow else { return } + pendingAccountFlow = nil + guard flow.ceremonies.scope == session.account?.scope else { flow.ceremonies.invalidate(); return } + accountFlow = flow + } + + // Admission is synchronous with onOpenURL: account routes cannot adopt + // a replacement scope while waiting for a scheduled task to start. + func receiveLink(_ url: URL) { + if let link = IncomingAccountLink(url: url, configuration: configuration) { + if let ready = accountLinkInbox.offer(link, phase: session.phase, scope: session.account?.scope) { + presentAccountLink(ready) + } + } else { + // Native callbacks still require the live coordinator's attempt; + // stale grants never enter the restoration inbox. + Task { await signIn.receiveCallback(url) } + } + } + + func resumeAccountLinks() { + guard let ready = accountLinkInbox.take(phase: session.phase, scope: session.account?.scope) else { return } + presentAccountLink(ready) + } + + private func presentAccountLink(_ link: IncomingAccountLink) { + switch link { + case .verify(let code): presentAccount(.verification, code: code) + case .passwordReset(let code): presentAccount(.reset, code: code) + } + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Audio/Audio.xcstrings b/apps/maple-ios/Sources/MapleAppUI/Audio/Audio.xcstrings new file mode 100644 index 000000000..b26f9903e --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Audio/Audio.xcstrings @@ -0,0 +1,258 @@ +{ + "sourceLanguage": "en", + "strings": { + "%@ elapsed, %@ total": { + "comment": "Native reply-audio player. Keep the full accessible action or status message. No chat title or response content appears in system media metadata.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ elapsed, %@ total" + } + } + } + }, + "Autoplay replies": { + "comment": "Native reply-audio player. Keep the full accessible action or status message. No chat title or response content appears in system media metadata.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Autoplay replies" + } + } + } + }, + "Close reply player": { + "comment": "Native reply-audio player. Keep the full accessible action or status message. No chat title or response content appears in system media metadata.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close reply player" + } + } + } + }, + "Finish or cancel dictation before playing reply audio.": { + "comment": "Native reply-audio player. Keep the full accessible action or status message. No chat title or response content appears in system media metadata.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Finish or cancel dictation before playing reply audio." + } + } + } + }, + "Go back ten seconds": { + "comment": "Native reply-audio player. Keep the full accessible action or status message. No chat title or response content appears in system media metadata.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Go back ten seconds" + } + } + } + }, + "Go forward ten seconds": { + "comment": "Native reply-audio player. Keep the full accessible action or status message. No chat title or response content appears in system media metadata.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Go forward ten seconds" + } + } + } + }, + "Maple": { + "comment": "Native reply-audio player. Keep the full accessible action or status message. No chat title or response content appears in system media metadata.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Maple" + } + } + } + }, + "Pause reply audio": { + "comment": "Native reply-audio player. Keep the full accessible action or status message. No chat title or response content appears in system media metadata.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pause reply audio" + } + } + } + }, + "Play reply audio": { + "comment": "Native reply-audio player. Keep the full accessible action or status message. No chat title or response content appears in system media metadata.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Play reply audio" + } + } + } + }, + "Playback speed": { + "comment": "Native reply-audio player. Keep the full accessible action or status message. No chat title or response content appears in system media metadata.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Playback speed" + } + } + } + }, + "Preparing reply audio": { + "comment": "Native reply-audio player. Keep the full accessible action or status message. No chat title or response content appears in system media metadata.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preparing reply audio" + } + } + } + }, + "Read aloud requires an eligible paid plan.": { + "comment": "Native reply-audio player. Keep the full accessible action or status message. No chat title or response content appears in system media metadata.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Read aloud requires an eligible paid plan." + } + } + } + }, + "Reply audio is temporarily unavailable. Try again.": { + "comment": "Native reply-audio player. Keep the full accessible action or status message. No chat title or response content appears in system media metadata.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reply audio is temporarily unavailable. Try again." + } + } + } + }, + "Reply audio progress": { + "comment": "Native reply-audio player. Keep the full accessible action or status message. No chat title or response content appears in system media metadata.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reply audio progress" + } + } + } + }, + "Retry audio cleanup": { + "comment": "Retry clearing only the retired listening session temporary audio files after an operating-system storage failure.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Retry audio cleanup" + } + } + } + }, + "Temporary reply audio could not be stored or cleared. Try again.": { + "comment": "Native reply-audio player. Keep the full accessible action or status message. No chat title or response content appears in system media metadata.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Temporary reply audio could not be stored or cleared. Try again." + } + } + } + }, + "The reply audio could not be played. Try again.": { + "comment": "Native reply-audio player. Keep the full accessible action or status message. No chat title or response content appears in system media metadata.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The reply audio could not be played. Try again." + } + } + } + }, + "This audio request exceeds the player’s current limits. Your message text is unchanged.": { + "comment": "Native reply-audio player. Keep the full accessible action or status message. No chat title or response content appears in system media metadata.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This audio request exceeds the player’s current limits. Your message text is unchanged." + } + } + } + }, + "This listening session has ended.": { + "comment": "Native reply-audio player. Keep the full accessible action or status message. No chat title or response content appears in system media metadata.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This listening session has ended." + } + } + } + }, + "This reply has no text to read aloud.": { + "comment": "Native reply-audio player. Keep the full accessible action or status message. No chat title or response content appears in system media metadata.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This reply has no text to read aloud." + } + } + } + }, + "Your account’s current audio allowance has been reached.": { + "comment": "Native reply-audio player. Keep the full accessible action or status message. No chat title or response content appears in system media metadata.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your account’s current audio allowance has been reached." + } + } + } + } + }, + "version": "1.0" +} diff --git a/apps/maple-ios/Sources/MapleAppUI/Audio/CompactReplyPlayer.swift b/apps/maple-ios/Sources/MapleAppUI/Audio/CompactReplyPlayer.swift new file mode 100644 index 000000000..98d2fc91c --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Audio/CompactReplyPlayer.swift @@ -0,0 +1,159 @@ +#if os(iOS) + import MapleAppCore + import SwiftUI + + public struct CompactReplyPlayer: View { + @Bindable private var session: ReplyListeningSession + public init(session: ReplyListeningSession) { self.session = session } + + public var body: some View { + if session.cleanupRequired && !session.isPresented { + VStack { + SpeechIssueText(issue: .storage) + Button { + Task { try? await session.retryCleanup() } + } label: { + Text("Retry audio cleanup", tableName: "Audio", bundle: .module) + } + }.font(.callout).padding() + } else if session.isPresented { + VStack(spacing: 8) { + HStack { + Text("Maple", tableName: "Audio", bundle: .module).font(.headline) + Spacer() + if let chatID = session.current?.chatID ?? session.pending.first?.chatID { + Toggle( + isOn: Binding( + get: { session.autoplayEnabled(chatID: chatID) }, + set: { session.setAutoplay($0, chatID: chatID) } + ) + ) { Text("Autoplay replies", tableName: "Audio", bundle: .module) } + .labelsHidden().accessibilityLabel( + Text("Autoplay replies", tableName: "Audio", bundle: .module) + ) + .accessibilityIdentifier("audio.autoplay") + } + Button { + session.close() + } label: { + Image(systemName: "xmark") + } + .frame(minWidth: 44, minHeight: 44) + .accessibilityLabel(Text("Close reply player", tableName: "Audio", bundle: .module)) + .accessibilityIdentifier("audio.close") + } + ViewThatFits(in: .horizontal) { + HStack(spacing: 16) { + transportControls + Spacer(minLength: 0) + speedControl + } + VStack(spacing: 8) { + transportControls + speedControl + } + } + if session.duration > 0 { + ProgressView(value: session.position, total: session.duration) + .accessibilityLabel(Text("Reply audio progress", tableName: "Audio", bundle: .module)) + .accessibilityValue( + Text( + "\(Duration.seconds(session.position).formatted(.units(allowed: [.minutes, .seconds], width: .abbreviated))) elapsed, \(Duration.seconds(session.duration).formatted(.units(allowed: [.minutes, .seconds], width: .abbreviated))) total", + tableName: "Audio", bundle: .module)) + } + if let issue = session.issue { SpeechIssueText(issue: issue).font(.footnote) } + } + .padding(.horizontal, 12).padding(.vertical, 8) + .glassEffect(.regular, in: RoundedRectangle(cornerRadius: 20)) + .accessibilityElement(children: .contain).accessibilityIdentifier("audio.player") + } + } + private var transportControls: some View { + HStack(spacing: 16) { + Button { + session.skip(seconds: -10) + } label: { + Image(systemName: "gobackward.10") + } + .frame(minWidth: 44, minHeight: 44).disabled(session.duration <= 0) + .accessibilityLabel(Text("Go back ten seconds", tableName: "Audio", bundle: .module)) + .accessibilityIdentifier("audio.back") + Button { + if session.phase == .playing { session.pause() } else { session.play() } + } label: { + Image(systemName: session.phase == .playing ? "pause.fill" : "play.fill") + } + .frame(minWidth: 44, minHeight: 44).disabled(!session.canPlay) + .accessibilityLabel(playPauseLabel) + .accessibilityIdentifier("audio.play-pause") + Button { + session.skip(seconds: 10) + } label: { + Image(systemName: "goforward.10") + } + .frame(minWidth: 44, minHeight: 44).disabled(session.duration <= 0) + .accessibilityLabel(Text("Go forward ten seconds", tableName: "Audio", bundle: .module)) + .accessibilityIdentifier("audio.forward") + }.fixedSize() + } + private var playPauseLabel: Text { + if session.phase == .playing { + Text("Pause reply audio", tableName: "Audio", bundle: .module) + } else { + Text("Play reply audio", tableName: "Audio", bundle: .module) + } + } + private var speedControl: some View { + Menu { + Picker(selection: Binding(get: { session.speed }, set: { session.setSpeed($0) })) { + ForEach(MaplePlaybackSpeed.allCases, id: \.self) { speed in + Text( + verbatim: + "\(speed.rawValue.formatted(.number.precision(.fractionLength(0...2))))×" + ).tag(speed) + } + } label: { + Text("Playback speed", tableName: "Audio", bundle: .module) + } + } label: { + Text( + verbatim: + "\(session.speed.rawValue.formatted(.number.precision(.fractionLength(0...2))))×" + ) + .font(.custom("GeistMono-Regular", size: 17, relativeTo: .body)).monospacedDigit().frame( + minWidth: 44, minHeight: 44) + } + .accessibilityLabel(Text("Playback speed", tableName: "Audio", bundle: .module)) + .accessibilityIdentifier("audio.speed") + } + + } + + private struct SpeechIssueText: View { + let issue: SpeechIssue + var body: some View { + switch issue { + case .emptyText: Text("This reply has no text to read aloud.", tableName: "Audio", bundle: .module) + case .capacity: + Text( + "This audio request exceeds the player’s current limits. Your message text is unchanged.", + tableName: "Audio", bundle: .module) + case .invalidAudio: + Text("The reply audio could not be played. Try again.", tableName: "Audio", bundle: .module) + case .unavailable: + Text("Reply audio is temporarily unavailable. Try again.", tableName: "Audio", bundle: .module) + case .storage: + Text( + "Temporary reply audio could not be stored or cleared. Try again.", tableName: "Audio", + bundle: .module) + case .retired: Text("This listening session has ended.", tableName: "Audio", bundle: .module) + case .dictationActive: + Text("Finish or cancel dictation before playing reply audio.", tableName: "Audio", bundle: .module) + case .paidPlanRequired: + Text("Read aloud requires an eligible paid plan.", tableName: "Audio", bundle: .module) + case .usageLimit: + Text("Your account’s current audio allowance has been reached.", tableName: "Audio", bundle: .module) + } + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Audio/LocalListeningFixture.swift b/apps/maple-ios/Sources/MapleAppUI/Audio/LocalListeningFixture.swift new file mode 100644 index 000000000..8def2f500 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Audio/LocalListeningFixture.swift @@ -0,0 +1,194 @@ +#if os(iOS) && DEBUG + import Foundation + import MapleAppCore + import MapleSDK + import Observation + import SwiftUI + + /// A local-only request timing seam. Requests and terminal events still come + /// from the captured SDK service; no response or ownership callback is forged. + @MainActor @Observable final class LocalListeningRequestGate { + private(set) var armed = false + private(set) var waiting = false + private var valid = true + private var waiter: (UUID, CheckedContinuation)? + + func arm() { guard valid, !armed, waiter == nil else { return }; armed = true } + func release() { + guard valid else { return } + armed = false; waiting = false + let old = waiter; waiter = nil; old?.1.resume() + } + func retire() { + valid = false; armed = false; waiting = false + let old = waiter; waiter = nil; old?.1.resume(throwing: CancellationError()) + } + func pass() async throws { + try Task.checkCancellation() + guard valid else { throw CancellationError() } + guard armed else { return } + guard waiter == nil else { throw ChatIssue.busy } + let id = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + waiter = (id, continuation); waiting = true + } + } onCancel: { + Task { @MainActor [weak self] in self?.cancel(id) } + } + try Task.checkCancellation() + guard valid else { throw CancellationError() } + } + private func cancel(_ id: UUID) { + guard waiter?.0 == id else { return } + let old = waiter; waiter = nil; armed = false; waiting = false + old?.1.resume(throwing: CancellationError()) + } + } + + struct LocalListeningChatService: ChatServing { + let base: any ChatServing + let gate: LocalListeningRequestGate + func createConversation(metadata: [String: JSONValue]?, projectId: String?, pinned: Bool?) async throws + -> Conversation + { try await base.createConversation(metadata: metadata, projectId: projectId, pinned: pinned) } + func listConversations(_ parameters: ConversationsListParameters) async throws -> APIPage { + try await base.listConversations(parameters) + } + func createResponse(_ request: ResponsesCreateRequest, maximumInferenceSends: Int) async throws + -> AsyncThrowingStream + { + try await gate.pass() + return try await base.createResponse(request, maximumInferenceSends: maximumInferenceSends) + } + func retrieveResponse(id: String) async throws -> ResearchResponse { try await base.retrieveResponse(id: id) } + func cancelResponse(id: String) async throws -> ResearchResponse { try await base.cancelResponse(id: id) } + func getConversationItem(conversationId: String, itemId: String) async throws -> ConversationItem { + try await base.getConversationItem(conversationId: conversationId, itemId: itemId) + } + func listConversationItems(conversationId: String, parameters: ListParameters) async throws + -> APIPage + { try await base.listConversationItems(conversationId: conversationId, parameters: parameters) } + } + + /// Read-only snapshots at the actual root callbacks. Retains bounded opaque + /// IDs and state only, never the account principal, prompt, response or audio. + @MainActor @Observable final class LocalListeningProof { + let scope: AccountScope + let requestGate = LocalListeningRequestGate() + private weak var session: ReplyListeningSession? + private var pendingTranscripts = 0 + private var finishedTranscripts = 0 + private var acceptedCount = 0 + private var acceptedID = "none" + private var acceptedChat = "none" + private var acceptedArm = "none" + private var acceptedCurrent = "none" + private var acceptedPhase = "closed" + private var acceptedHold = "none" + private var assignedChat = "none" + private var assignedArm = "none" + private var completions = 0 + private var completionID = "none" + private var completionMessage = "none" + private var completionChat = "none" + private var completionVisible = false + private var completionForeground = false + + init(scope: AccountScope, session: ReplyListeningSession) { self.scope = scope; self.session = session } + func accepted(_ value: ChatSendAcceptance, armedMessageID: String?) { + guard value.scope == scope, let session else { return } + acceptedCount += 1; acceptedID = Self.id(value.messageID) + acceptedChat = Self.id(value.conversationID); acceptedArm = Self.id(armedMessageID) + acceptedCurrent = Self.id(session.current?.responseID) + acceptedPhase = Self.phase(session.phase); acceptedHold = Self.hold(session.dictationHold) + } + func assigned(scope: AccountScope, chatID: String, armedMessageID: String?) { + guard scope == self.scope, session != nil else { return } + assignedChat = Self.id(chatID); assignedArm = Self.id(armedMessageID) + } + func completed(_ value: ChatCompletion) { + guard value.scope == scope, session != nil else { return } + completions += 1; completionID = Self.id(value.responseID ?? value.runID.uuidString) + completionMessage = Self.id(value.messageID); completionChat = Self.id(value.conversationID) + completionVisible = value.wasVisible; completionForeground = value.wasForeground + } + func inspectTranscripts(_ count: Int) { + guard session != nil else { return }; pendingTranscripts = count + } + func transcriptFinished(_ count: Int) { + guard session != nil else { return }; finishedTranscripts += count + } + func retire() { requestGate.retire(); session = nil } + + var diagnostics: String { + let value: [String: Any] = [ + "phase": session.map { Self.phase($0.phase) } ?? "closed", + "hold": session.map { Self.hold($0.dictationHold) } ?? "none", + "manual": session?.manualPaused ?? false, + "current": Self.id(session?.current?.responseID), + "currentTurn": Self.id(session?.current?.turnID), + "pending": session?.pending.map { Self.id($0.responseID) } ?? [], + "pendingTurns": session?.pending.map { Self.id($0.turnID) } ?? [], + "duration": session?.duration ?? 0, "speed": session?.speed.rawValue ?? 1, + "position": session?.position ?? 0, + "autoplay": session?.autoplayChats.isEmpty == false, + "armed": requestGate.armed, "waiting": requestGate.waiting, + "pendingTranscripts": pendingTranscripts, "finishedTranscripts": finishedTranscripts, + "acceptedCount": acceptedCount, "acceptedID": acceptedID, + "acceptedChat": acceptedChat, "acceptedArm": acceptedArm, + "acceptedCurrent": acceptedCurrent, "acceptedPhase": acceptedPhase, "acceptedHold": acceptedHold, + "assignedChat": assignedChat, "assignedArm": assignedArm, + "completions": completions, "completionID": completionID, + "completionMessage": completionMessage, "completionChat": completionChat, + "completionVisible": completionVisible, "completionForeground": completionForeground, + ] + guard let data = try? JSONSerialization.data(withJSONObject: value, options: .sortedKeys), + data.count <= 8_192, let text = String(data: data, encoding: .utf8) + else { return "unavailable" } + return text + } + private static func id(_ value: String?) -> String { value.map { String($0.prefix(128)) } ?? "none" } + private static func phase(_ value: ReplyListeningSession.Phase) -> String { + switch value { + case .closed: "closed" + case .preparing: "preparing" + case .playing: "playing" + case .paused: "paused" + case .ended: "ended" + case .failed: "failed" + } + } + private static func hold(_ value: ReplyListeningSession.DictationHold) -> String { + switch value { + case .none: "none" + case .capture: "capture" + case .transcription: "transcription" + case .review: "review" + } + } + } + + struct LocalListeningFixtureControls: View { + let proof: LocalListeningProof + let transcriber: LocalLifecycleTranscriber + var body: some View { + VStack(spacing: 0) { + HStack { + Button("Transcript") { + Task { proof.transcriptFinished(await transcriber.finish()) } + }.accessibilityIdentifier("lifecycle.transcript") + Button("Inspect") { + Task { proof.inspectTranscripts(await transcriber.pendingCount()) } + }.accessibilityIdentifier("listening.inspect-transcripts") + Button("Hold request") { proof.requestGate.arm() } + .disabled(proof.requestGate.armed).accessibilityIdentifier("listening.hold-request") + Button("Release") { proof.requestGate.release() } + .disabled(!proof.requestGate.waiting).accessibilityIdentifier("listening.release-request") + }.font(.caption).buttonStyle(.bordered).frame(minHeight: 44) + Text("Local listening diagnostics").font(.caption2) + .accessibilityIdentifier("listening.diagnostics").accessibilityValue(proof.diagnostics) + }.padding(.horizontal, 4).background(.background) + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Audio/NativeAccountDataLifecycle.swift b/apps/maple-ios/Sources/MapleAppUI/Audio/NativeAccountDataLifecycle.swift new file mode 100644 index 000000000..630b32dd8 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Audio/NativeAccountDataLifecycle.swift @@ -0,0 +1,30 @@ +#if os(iOS) + import MapleAppCore + + /// Startup and sign-out clear generated audio before a new account can use the + /// app. Chat persistence remains independently protected by its own actor. + actor NativeAccountDataLifecycle: AccountDataLifecycle { + let drafts: ProtectedChatStore + let speech: TemporarySpeechStore + let projectExpansion: LibraryProjectExpansionStore + init( + drafts: ProtectedChatStore, speech: TemporarySpeechStore, + projectExpansion: LibraryProjectExpansionStore + ) { + self.drafts = drafts; self.speech = speech; self.projectExpansion = projectExpansion + } + func activate(_ scope: AccountScope) async throws { + try await speech.startupCleanup() + try await projectExpansion.startupCleanup() + try await drafts.activate(scope) + } + func retire(_ scope: AccountScope) async throws { try await drafts.retire(scope) } + func clearSignedOutData() async throws { + var failed = false + do { try await drafts.clearSignedOutData() } catch { failed = true } + do { try await speech.startupCleanup() } catch { failed = true } + do { try await projectExpansion.startupCleanup() } catch { failed = true } + if failed { throw ChatStorageError.cleanupRequired } + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Audio/NativeSpeechPlayer.swift b/apps/maple-ios/Sources/MapleAppUI/Audio/NativeSpeechPlayer.swift new file mode 100644 index 000000000..9a3529d67 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Audio/NativeSpeechPlayer.swift @@ -0,0 +1,261 @@ +#if os(iOS) + import AVFoundation + import Foundation + import MapleAppCore + import MediaPlayer + + /// A single owner transfers these read-only handles from the file-loading + /// actor to the main-actor engine. The loader retains no reference after return. + private final class OpenSpeechFile: @unchecked Sendable { + let file: AVAudioFile + init(_ file: AVAudioFile) { self.file = file } + } + + private actor NativeSpeechFileLoader { + func load(_ chunks: [SpeechChunk]) throws -> [OpenSpeechFile] { + guard !chunks.isEmpty, chunks.count <= SpeechLimits.maximumChunks else { throw SpeechIssue.invalidAudio } + return try chunks.map { chunk in + try Task.checkCancellation() + let file = try AVAudioFile(forReading: chunk.file.url) + let duration = Double(file.length) / file.processingFormat.sampleRate + guard file.length > 0, file.length <= AVAudioFramePosition(UInt32.max), + file.processingFormat.sampleRate.isFinite, + (8_000...192_000).contains(file.processingFormat.sampleRate), + (1...2).contains(file.processingFormat.channelCount), + duration.isFinite, abs(duration - chunk.duration) < 0.01 + else { throw SpeechIssue.invalidAudio } + return OpenSpeechFile(file) + } + } + } + + public actor NativeSpeechInspector: SpeechMediaInspecting { + public init() {} + public func duration(of file: StoredSpeechFile) throws -> TimeInterval { + let audio = try AVAudioFile(forReading: file.url) + let rate = audio.processingFormat.sampleRate + guard rate.isFinite, (8_000...192_000).contains(rate), + (1...2).contains(audio.processingFormat.channelCount), + audio.length > 0, audio.length <= AVAudioFramePosition(UInt32.max) + else { throw SpeechIssue.invalidAudio } + return Double(audio.length) / rate + } + } + + /// Completed files are scheduled before Play. The time-pitch unit applies the + /// saved rate exactly once with pitch zero; synthesis always requested rate 1. + @MainActor public final class NativeSpeechPlayer: SpeechPlaying { + public var onEvent: (@MainActor (SpeechPlayerEvent) -> Void)? + public private(set) var duration: TimeInterval = 0 + private let engine = AVAudioEngine() + private let node = AVAudioPlayerNode() + private let timePitch = AVAudioUnitTimePitch() + private let loader = NativeSpeechFileLoader() + private var files: [OpenSpeechFile] = [] + private var basePosition: TimeInterval = 0 + private var pausedPosition: TimeInterval = 0 + private var isPlaying = false + private var needsReschedule = false + private var speed: MaplePlaybackSpeed = .normal + private var generation = UUID() + private var playbackID: UUID? + private var listeningID: UUID? + private weak var listening: ReplyListeningSession? + private var targets: [(MPRemoteCommand, Any)] = [] + private var notifications: [NSObjectProtocol] = [] + private var metadataTask: Task? + + public init() { + engine.attach(node); engine.attach(timePitch) + timePitch.pitch = 0 + } + public func bind(to session: ReplyListeningSession) { listening = session } + + public var position: TimeInterval { + guard isPlaying, let render = node.lastRenderTime, let time = node.playerTime(forNodeTime: render), + time.sampleRate > 0 + else { return pausedPosition } + return min(duration, max(0, basePosition + Double(time.sampleTime) / time.sampleRate)) + } + + public func load(_ speech: PreparedSpeech, playbackID: UUID) async throws { + stop() + let ticket = generation + let loaded = try await loader.load(speech.chunks) + guard generation == ticket else { throw SpeechIssue.retired } + guard let first = loaded.first?.file.processingFormat, + loaded.allSatisfy({ + $0.file.processingFormat.sampleRate == first.sampleRate + && $0.file.processingFormat.channelCount == first.channelCount + }) + else { throw SpeechIssue.invalidAudio } + files = loaded; duration = speech.duration; self.playbackID = playbackID; + listeningID = speech.owner.sessionID + engine.disconnectNodeOutput(node); engine.disconnectNodeOutput(timePitch) + engine.connect(node, to: timePitch, format: first) + engine.connect(timePitch, to: engine.mainMixerNode, format: first) + timePitch.pitch = 0; timePitch.rate = Float(speed.rawValue) + schedule(from: 0) + installCommands(playbackID: playbackID, listeningID: speech.owner.sessionID) + installNotifications(playbackID: playbackID) + } + + public func play() throws { + guard playbackID != nil, !files.isEmpty else { throw SpeechIssue.invalidAudio } + if pausedPosition >= duration { if let playbackID { onEvent?(.ended(playbackID)) }; return } + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playback, mode: .spokenAudio, options: []) + try session.setActive(true) + if !engine.isRunning { try engine.start() } + if needsReschedule { schedule(from: pausedPosition); needsReschedule = false } + isPlaying = true; node.play(); publishMetadata() + startMetadataUpdates() + } + public func pause() { + pausedPosition = position; node.pause(); isPlaying = false + publishMetadata() + } + public func stop() { + generation = UUID(); playbackID = nil; listeningID = nil + metadataTask?.cancel(); metadataTask = nil + node.stop(); engine.stop(); engine.reset() + files = []; duration = 0; basePosition = 0; pausedPosition = 0; isPlaying = false + needsReschedule = false + for (command, target) in targets { command.removeTarget(target) }; targets = [] + for token in notifications { NotificationCenter.default.removeObserver(token) }; notifications = [] + MPNowPlayingInfoCenter.default().nowPlayingInfo = nil + MPNowPlayingInfoCenter.default().playbackState = .stopped + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } + public func seek(to position: TimeInterval) throws { + guard playbackID != nil, position.isFinite else { throw SpeechIssue.invalidAudio } + let resume = isPlaying + node.stop(); isPlaying = false + schedule(from: min(duration, max(0, position))) + needsReschedule = false + if resume { try play() } else { publishMetadata() } + } + public func setSpeed(_ speed: MaplePlaybackSpeed) { + self.speed = speed; timePitch.rate = Float(speed.rawValue); publishMetadata() + } + + private func schedule(from position: TimeInterval) { + basePosition = position; pausedPosition = position + let scheduleID = UUID(); generation = scheduleID + guard let playbackID else { return } + var skipped: TimeInterval = 0 + for (index, opened) in files.enumerated() { + let file = opened.file, rate = file.processingFormat.sampleRate + let length = Double(file.length) / rate + defer { skipped += length } + guard position < skipped + length else { continue } + let startingFrame = min(file.length, AVAudioFramePosition(max(0, position - skipped) * rate)) + let frames = AVAudioFrameCount(file.length - startingFrame) + guard frames > 0 else { continue } + node.scheduleSegment( + file, startingFrame: startingFrame, frameCount: frames, at: nil, + completionCallbackType: .dataPlayedBack + ) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self, self.generation == scheduleID, self.playbackID == playbackID, + index == self.files.count - 1 + else { return } + self.pausedPosition = self.duration; self.isPlaying = false + self.publishMetadata(); self.onEvent?(.ended(playbackID)) + } + } + } + } + + private func startMetadataUpdates() { + guard metadataTask == nil else { return } + let id = playbackID + metadataTask = Task { [weak self] in + while !Task.isCancelled { + do { try await Task.sleep(for: .milliseconds(500)) } catch { return } + guard let self, self.playbackID == id else { return } + self.publishMetadata(); self.listening?.refreshPosition() + } + } + } + private func publishMetadata() { + guard playbackID != nil else { return } + MPNowPlayingInfoCenter.default().nowPlayingInfo = [ + MPMediaItemPropertyTitle: "Maple", + MPMediaItemPropertyPlaybackDuration: duration, + MPNowPlayingInfoPropertyElapsedPlaybackTime: position, + MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? speed.rawValue : 0, + MPNowPlayingInfoPropertyDefaultPlaybackRate: speed.rawValue, + ] + MPNowPlayingInfoCenter.default().playbackState = isPlaying ? .playing : .paused + } + private func installCommands(playbackID: UUID, listeningID: UUID) { + let center = MPRemoteCommandCenter.shared() + center.skipBackwardCommand.preferredIntervals = [10] + center.skipForwardCommand.preferredIntervals = [10] + let commands: [(MPRemoteCommand, SpeechRemoteCommand)] = [ + (center.playCommand, .play), (center.pauseCommand, .pause), (center.togglePlayPauseCommand, .toggle), + (center.skipBackwardCommand, .backwardTenSeconds), (center.skipForwardCommand, .forwardTenSeconds), + ] + for (command, action) in commands { + command.isEnabled = true + let target = command.addTarget { [weak self] _ in + Self.onMain { + guard let self, self.playbackID == playbackID else { return false } + return self.listening?.remote(action, sessionID: listeningID) ?? false + } ? .success : .commandFailed + } + targets.append((command, target)) + } + } + private nonisolated static func onMain(_ action: @MainActor @Sendable () -> Bool) -> Bool { + if Thread.isMainThread { return MainActor.assumeIsolated { action() } } + return DispatchQueue.main.sync { MainActor.assumeIsolated { action() } } + } + private func installNotifications(playbackID: UUID) { + let center = NotificationCenter.default + notifications.append( + center.addObserver(forName: .AVAudioEngineConfigurationChange, object: engine, queue: .main) { + [weak self] _ in + Task { @MainActor [weak self] in + guard let self, self.playbackID == playbackID, !self.engine.isRunning else { return } + self.pausedPosition = self.position + self.generation = UUID(); self.isPlaying = false; self.needsReschedule = true + self.node.stop() + // A graph or route reconfiguration must not restart a + // previously playing reply without a new explicit Play. + if let listening = self.listening, listening.dictationHold == .none { + listening.routeDisconnected() + } + } + }) + notifications.append( + center.addObserver(forName: AVAudioSession.routeChangeNotification, object: nil, queue: .main) { + [weak self] note in + let raw = (note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? NSNumber)?.uintValue + Task { @MainActor [weak self] in + guard let self, self.playbackID == playbackID else { return } + if raw == AVAudioSession.RouteChangeReason.oldDeviceUnavailable.rawValue { + self.listening?.routeDisconnected() + } + // A new route alone deliberately never resumes playback. + } + }) + notifications.append( + center.addObserver(forName: AVAudioSession.interruptionNotification, object: nil, queue: .main) { + [weak self] note in + let raw = (note.userInfo?[AVAudioSessionInterruptionTypeKey] as? NSNumber)?.uintValue + let options = (note.userInfo?[AVAudioSessionInterruptionOptionKey] as? NSNumber)?.uintValue ?? 0 + Task { @MainActor [weak self] in + guard let self, self.playbackID == playbackID else { return } + if raw == AVAudioSession.InterruptionType.began.rawValue { self.listening?.interruptionBegan() } + if raw == AVAudioSession.InterruptionType.ended.rawValue { + self.listening?.interruptionEnded( + shouldResume: AVAudioSession.InterruptionOptions(rawValue: options).contains( + .shouldResume)) + } + } + }) + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Audio/ReplyPlayerFixtureView.swift b/apps/maple-ios/Sources/MapleAppUI/Audio/ReplyPlayerFixtureView.swift new file mode 100644 index 000000000..bbca46f57 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Audio/ReplyPlayerFixtureView.swift @@ -0,0 +1,179 @@ +#if os(iOS) && DEBUG + import Foundation + import MapleAppCore + import MediaPlayer + import Observation + import SwiftUI + + /// No SDK client or network. Only synthesis is replaced with a bounded local + /// tone; the native engine, protected files, preferences and player UI are real. + struct ReplyPlayerFixtureView: View { + @State private var model = ReplyPlayerFixtureModel() + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text("Synthetic native reply player").font(.headline) + Text(model.ready ? "ready" : "preparing").accessibilityIdentifier("player.fixture.ready") + Button("Play synthetic reply") { model.play() } + .accessibilityIdentifier("player.fixture.play").disabled(!model.ready) + Button("Change visible chat") { model.changeChat() } + .accessibilityIdentifier("player.fixture.change-chat") + Button("Complete eligible synthetic reply") { model.completeEligible() } + .accessibilityIdentifier("player.fixture.complete") + Text(model.visibleChat).accessibilityIdentifier("player.fixture.visible-chat") + Text(model.session.current?.responseID ?? "none").accessibilityIdentifier("player.fixture.current") + Text(model.session.phase == .ended ? "ended" : "other").accessibilityIdentifier( + "player.fixture.ended") + Button("Commit preference checkpoint") { Task { await model.checkpoint() } } + .accessibilityIdentifier("player.fixture.checkpoint") + Text(model.checkpointState).accessibilityIdentifier("player.fixture.saved") + Text(model.metadataPolicy).accessibilityIdentifier("player.fixture.metadata-policy") + Text(String(model.rate)).accessibilityIdentifier("player.fixture.rate") + Text(String(model.defaultRate)).accessibilityIdentifier("player.fixture.default-rate") + Text(String(Int(model.elapsed))).accessibilityIdentifier("player.fixture.elapsed") + Text(String(model.bytes)).accessibilityIdentifier("player.fixture.bytes") + Text(String(model.preferences.value.playbackSpeed.rawValue)) + .accessibilityIdentifier("player.fixture.saved-speed") + Text(model.issue).accessibilityIdentifier("player.fixture.issue") + }.padding() + } + .safeAreaInset(edge: .bottom) { CompactReplyPlayer(session: model.session).padding(8) } + .task { + await model.start() + while !Task.isCancelled { + await model.sample() + do { try await Task.sleep(nanoseconds: 200_000_000) } catch { return } + } + } + } + } + + @MainActor @Observable private final class ReplyPlayerFixtureModel { + private static let suite = "cloud.opensecret.maple.native.dev.synthetic-player-proof" + private(set) var visibleChat = "synthetic-chat" + private var completionCount = 0 + let session: ReplyListeningSession + let preferences: PreferencesController + private let files: TemporarySpeechStore + var ready = false + var checkpointState = "not saved" + var metadataPolicy = "absent" + var rate = -1.0 + var defaultRate = -1.0 + var elapsed = 0.0 + var bytes = 0 + var issue = "none" + private var started = false + + init() { + let scope = AccountScope(apiOrigin: "https://synthetic-player.invalid", principalID: "player-fixture") + files = TemporarySpeechStore( + root: FileManager.default.temporaryDirectory.appendingPathComponent( + "MapleSyntheticPlayer", isDirectory: true)) + preferences = PreferencesController(scope: scope, store: PreferencesStore(suiteName: Self.suite)) + let player = NativeSpeechPlayer() + session = ReplyListeningSession(scope: scope, preparer: ReplyTonePreparer(files: files), player: player) + player.bind(to: session) + session.setVisibility(chatID: "synthetic-chat", foreground: true) + preferences.onChange = { [weak session = self.session] value in + session?.updatePreferences(voice: value.voice, speed: value.playbackSpeed) + } + session.onSpeedChanged = { [weak preferences = self.preferences] speed in + preferences?.setPlaybackSpeed(speed) + } + } + + func start() async { + guard !started else { return }; started = true + if ProcessInfo.processInfo.arguments.contains("--maple-player-fixture-reset") { + UserDefaults.standard.removePersistentDomain(forName: Self.suite) + } + do { + try await files.startupCleanup() + await preferences.load() + guard preferences.state == .ready else { issue = "preferences unavailable"; return } + session.updatePreferences(voice: preferences.value.voice, speed: preferences.value.playbackSpeed) + ready = true + } catch { issue = "storage unavailable" } + } + func play() { + guard ready else { return } + checkpointState = "not saved" + session.playReply( + SpeechReply(chatID: visibleChat, responseID: UUID().uuidString, text: "Synthetic reply only.")) + } + func changeChat() { + visibleChat = visibleChat == "synthetic-chat" ? "synthetic-other-chat" : "synthetic-chat" + session.setVisibility(chatID: visibleChat, foreground: true) + } + func completeEligible() { + guard ready else { return }; completionCount += 1 + let response = "automatic-reply-\(completionCount)" + session.completed( + SpeechReply( + chatID: visibleChat, responseID: response, text: "Synthetic eligible completion.", + turnID: UUID().uuidString.lowercased()), + wasVisible: true, wasForeground: true) + } + + func checkpoint() async { + await preferences.flush() + checkpointState = preferences.saveIssue == nil ? "saved" : "failed" + } + func sample() async { + bytes = await files.allocatedBytes + let values = MPNowPlayingInfoCenter.default().nowPlayingInfo + let allowed: Set = [ + MPMediaItemPropertyTitle, MPMediaItemPropertyPlaybackDuration, + MPNowPlayingInfoPropertyElapsedPlaybackTime, MPNowPlayingInfoPropertyPlaybackRate, + MPNowPlayingInfoPropertyDefaultPlaybackRate, + ] + if let values { + let duration = (values[MPMediaItemPropertyPlaybackDuration] as? NSNumber)?.doubleValue + metadataPolicy = + Set(values.keys) == allowed && values[MPMediaItemPropertyTitle] as? String == "Maple" + && duration == 30 ? "Maple-only" : "unexpected" + rate = (values[MPNowPlayingInfoPropertyPlaybackRate] as? NSNumber)?.doubleValue ?? -1 + defaultRate = (values[MPNowPlayingInfoPropertyDefaultPlaybackRate] as? NSNumber)?.doubleValue ?? -1 + elapsed = (values[MPNowPlayingInfoPropertyElapsedPlaybackTime] as? NSNumber)?.doubleValue ?? 0 + } else { + metadataPolicy = "absent"; rate = -1; defaultRate = -1; elapsed = 0 + } + } + } + + private actor ReplyTonePreparer: SpeechPreparing { + let files: TemporarySpeechStore + init(files: TemporarySpeechStore) { self.files = files } + func prepare(_ reply: SpeechReply, voice: MapleVoice, owner: SpeechOwner) async throws -> PreparedSpeech { + try Task.checkCancellation() + // Thirty seconds of quiet PCM16 at 16 kHz. It contains no microphone + // samples or user text; generation runs on this synthesis actor. + let count = 30 * 16_000 + var pcm = Data(count: count * 2) + pcm.withUnsafeMutableBytes { buffer in + let samples = buffer.bindMemory(to: Int16.self) + for index in 0.. { continuation in + for offset in stride(from: 0, to: wav.count, by: 64 * 1_024) { + continuation.yield(wav.subdata(in: offset.. String { + switch issue { + case .deviceTime: + MapleStrings.text( + "This device’s date or time appears incorrect. Check Date & Time and the time zone in Settings, then try again." + ) + case .certificateTiming: + MapleStrings.text( + "The secure connection’s certificate is not valid at the device’s current date and time. Try again in a moment. If this continues, check Date & Time and the time zone in Settings." + ) + } + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Auth/AuthCeremonyViews.swift b/apps/maple-ios/Sources/MapleAppUI/Auth/AuthCeremonyViews.swift new file mode 100644 index 000000000..643acb473 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Auth/AuthCeremonyViews.swift @@ -0,0 +1,375 @@ +#if os(iOS) + import MapleAppCore + import SwiftUI + + struct AuthIssueView: View { + let issue: AuthCeremonyIssue + var body: some View { + Text(message).foregroundStyle(MapleRedText()) + .accessibilityIdentifier("auth.ceremony-error") + } + private var message: String { + switch issue { + case .clock(let clock): AttestationClockText.message(clock) + case .invalidInput: + String( + localized: "Check the code and make sure your new passwords match. Use at least eight characters.", + table: "Auth", bundle: .module) + case .expired: + String( + localized: "This request expired. Start again to get a new code or sign-in request.", table: "Auth", + bundle: .module) + case .changedAccount: + String( + localized: "Your sign-in changed. Reopen this screen to continue.", table: "Auth", bundle: .module) + case .browserSignInUnavailable: + String( + localized: + "Browser sign-in isn’t available in this build. Sign in with email or an existing guest account.", + table: "Auth", bundle: .module) + case .invalidCallback: + String( + localized: "This sign-in response could not be verified. Start sign-in again.", table: "Auth", + bundle: .module) + case .connection: + String( + localized: "The request could not be completed. Check your connection and try again.", + table: "Auth", bundle: .module) + case .invalidCredentials: + String(localized: "The current password is incorrect.", table: "Auth", bundle: .module) + case .billingCleanupUnavailable: + String( + localized: + "Your account was not deleted because billing cleanup could not finish. Try again later or contact support.", + table: "Auth", bundle: .module) + case .cleanupRequired: + String( + localized: "Maple could not safely stop account activity. Your account was not deleted. Try again.", + table: "Auth", bundle: .module) + case .deletionUnconfirmed: + String( + localized: "Account deletion was not confirmed. Check your account before trying again.", + table: "Auth", bundle: .module) + } + } + } + + public struct PasswordResetView: View { + @Bindable private var ceremonies: AuthCeremonies + @Environment(\.dismiss) private var dismiss + @Environment(\.scenePhase) private var scenePhase + @State private var email = "" + @State private var code: String + @State private var password = "" + @State private var confirmation = "" + @State private var showWarning = false + private let incomingCode: String? + public init(ceremonies: AuthCeremonies, code: String? = nil) { + self.ceremonies = ceremonies; incomingCode = code; _code = State(initialValue: code ?? "") + _email = State(initialValue: ceremonies.accountEmail ?? "") + } + public var body: some View { + List { + if ceremonies.state == .completed(.passwordReset) { + Section { + Text( + "Your password was reset. Sign in with your new password.", tableName: "Auth", + bundle: .module) + Button { + dismiss() + } label: { + Text("Return to sign-in", tableName: "Auth", bundle: .module) + } + } + } else if case .resetCode(let address) = ceremonies.state { + resetConfirmation(address) + } else if ceremonies.isBusy, !password.isEmpty { + resetConfirmation(email) + } else { + Section { + TextField( + text: $email, + prompt: Text("Email", tableName: "Auth", bundle: .module).foregroundStyle( + MapleFieldPrompt()) + ) { Text("Email", tableName: "Auth", bundle: .module) } + .textContentType(.emailAddress).keyboardType(.emailAddress) + .textInputAutocapitalization(.never).autocorrectionDisabled().accessibilityIdentifier( + "auth.reset-email" + ) + .disabled(ceremonies.scope != nil) + Text( + "Password reset creates a new private key and removes private encrypted content, including chats and saved data. If you can still sign in, change your password in settings instead.", + tableName: "Auth", bundle: .module + ) + .font(.callout) + if !code.isEmpty { + Text( + "Start the reset in this app to use your code. A code alone cannot restore the private confirmation secret.", + tableName: "Auth", bundle: .module + ).font(.callout) + } + Button { + showWarning = true + } label: { + Text("Request password reset", tableName: "Auth", bundle: .module) + }.disabled(email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || ceremonies.isBusy) + .accessibilityIdentifier("auth.request-reset") + } + } + if let issue = ceremonies.issue { Section { AuthIssueView(issue: issue) } } + } + .listStyle(.plain) + .listRowBackground(Color.clear) + .scrollContentBackground(.hidden) + .background(MapleSurface.canvas) + .navigationTitle(Text("Reset password", tableName: "Auth", bundle: .module)) + .confirmationDialog( + Text("Resetting your password deletes private data", tableName: "Auth", bundle: .module), + isPresented: $showWarning, titleVisibility: .visible + ) { + Button(role: .destructive) { + Task { await ceremonies.requestPasswordReset(email: email) } + } label: { + Text("Continue with reset", tableName: "Auth", bundle: .module) + } + } message: { + Text( + "Try your current password or Apple, Google, or GitHub first. Resetting removes private encrypted chats and saved data.", + tableName: "Auth", bundle: .module) + } + .onChange(of: ceremonies.state) { _, value in + if value == .completed(.passwordReset) { password = ""; confirmation = ""; code = "" } + } + .onChange(of: incomingCode) { _, value in if let value { code = value } } + .onChange(of: scenePhase) { _, phase in if phase == .active { ceremonies.expireIfNeeded() } } + .onDisappear { + password = ""; confirmation = ""; Task { await ceremonies.cancel() } + } + } + @ViewBuilder private func resetConfirmation(_ address: String) -> some View { + Section { + Text(address).font(.callout).textSelection(.enabled) + TextField( + text: $code, + prompt: Text("Eight-character reset code", tableName: "Auth", bundle: .module).foregroundStyle( + MapleFieldPrompt()) + ) { Text("Eight-character reset code", tableName: "Auth", bundle: .module) } + .textContentType(.oneTimeCode).textInputAutocapitalization(.characters).autocorrectionDisabled() + .accessibilityIdentifier("auth.reset-code") + SecureField( + text: $password, + prompt: Text("New password", tableName: "Auth", bundle: .module).foregroundStyle(MapleFieldPrompt()) + ) { Text("New password", tableName: "Auth", bundle: .module) } + .textContentType(.newPassword).accessibilityIdentifier("auth.reset-password") + SecureField( + text: $confirmation, + prompt: Text("Confirm new password", tableName: "Auth", bundle: .module).foregroundStyle( + MapleFieldPrompt()) + ) { Text("Confirm new password", tableName: "Auth", bundle: .module) } + .textContentType(.newPassword).accessibilityIdentifier("auth.reset-confirm-password") + Text( + "If a code does not arrive, check spam or try the provider you used to create your account.", + tableName: "Auth", bundle: .module + ).font(.callout) + Button { + Task { + await ceremonies.confirmPasswordReset( + code: code, newPassword: password, confirmation: confirmation) + } + } label: { + Text("Reset password", tableName: "Auth", bundle: .module) + }.disabled(ceremonies.isBusy || code.isEmpty || password.isEmpty || confirmation.isEmpty) + .accessibilityIdentifier("auth.confirm-reset") + }.disabled(ceremonies.isBusy) + } + } + + public struct EmailVerificationView: View { + @Bindable private var ceremonies: AuthCeremonies + @State private var code: String + private let initialCode: String? + public init(ceremonies: AuthCeremonies, code: String? = nil) { + self.ceremonies = ceremonies; initialCode = code; _code = State(initialValue: code ?? "") + } + public var body: some View { + List { + if ceremonies.state == .completed(.emailVerified) { + Text("Your email is verified.", tableName: "Auth", bundle: .module).accessibilityIdentifier( + "auth.verified") + } else { + Section { + TextField( + text: $code, + prompt: Text("Verification code", tableName: "Auth", bundle: .module).foregroundStyle( + MapleFieldPrompt()) + ) { Text("Verification code", tableName: "Auth", bundle: .module) } + .textContentType(.oneTimeCode).textInputAutocapitalization(.never).autocorrectionDisabled() + .accessibilityIdentifier("auth.verify-code") + Button { + Task { await ceremonies.verifyEmail(code: code) } + } label: { + Text("Verify email", tableName: "Auth", bundle: .module) + }.disabled(ceremonies.isBusy || code.isEmpty).accessibilityIdentifier("auth.verify-submit") + if ceremonies.scope != nil { + Button { + Task { await ceremonies.resendVerification() } + } label: { + Text("Send another verification email", tableName: "Auth", bundle: .module) + }.disabled(ceremonies.isBusy).accessibilityIdentifier("auth.verify-resend") + } + if ceremonies.state == .completed(.verificationSent) { + Text("Check your inbox for a verification email.", tableName: "Auth", bundle: .module) + } + } + } + if let issue = ceremonies.issue { AuthIssueView(issue: issue) } + } + .listStyle(.plain) + .listRowBackground(Color.clear) + .scrollContentBackground(.hidden) + .background(MapleSurface.canvas) + .navigationTitle(Text("Verify email", tableName: "Auth", bundle: .module)) + .task { if let initialCode { await ceremonies.verifyEmail(code: initialCode) } } + .onChange(of: initialCode) { _, value in + guard let value else { return } + code = value + Task { await ceremonies.verifyEmail(code: value) } + } + .onDisappear { Task { await ceremonies.cancel() } } + } + } + + public struct ChangePasswordView: View { + @Bindable private var ceremonies: AuthCeremonies + @State private var current = "" + @State private var password = "" + @State private var confirmation = "" + public init(ceremonies: AuthCeremonies) { self.ceremonies = ceremonies } + public var body: some View { + List { + if ceremonies.state == .completed(.passwordChanged) { + Text("Your password was changed.", tableName: "Auth", bundle: .module).accessibilityIdentifier( + "auth.password-changed") + } else { + Section { + SecureField( + text: $current, + prompt: Text("Current password", tableName: "Auth", bundle: .module).foregroundStyle( + MapleFieldPrompt()) + ) { Text("Current password", tableName: "Auth", bundle: .module) } + .textContentType(.password).accessibilityIdentifier("auth.current-password") + SecureField( + text: $password, + prompt: Text("New password", tableName: "Auth", bundle: .module).foregroundStyle( + MapleFieldPrompt()) + ) { Text("New password", tableName: "Auth", bundle: .module) } + .textContentType(.newPassword).accessibilityIdentifier("auth.new-password") + SecureField( + text: $confirmation, + prompt: Text("Confirm new password", tableName: "Auth", bundle: .module).foregroundStyle( + MapleFieldPrompt()) + ) { Text("Confirm new password", tableName: "Auth", bundle: .module) } + .textContentType(.newPassword).accessibilityIdentifier("auth.confirm-new-password") + Text("Use at least eight characters.", tableName: "Auth", bundle: .module).font(.callout) + Button { + Task { + await ceremonies.changePassword( + currentPassword: current, newPassword: password, confirmation: confirmation) + } + } label: { + Text("Change password", tableName: "Auth", bundle: .module) + }.disabled(ceremonies.isBusy || current.isEmpty || password.isEmpty || confirmation.isEmpty) + .accessibilityIdentifier("auth.change-password") + }.disabled(ceremonies.isBusy) + } + if let issue = ceremonies.issue { AuthIssueView(issue: issue) } + } + .listStyle(.plain) + .listRowBackground(Color.clear) + .scrollContentBackground(.hidden) + .background(MapleSurface.canvas) + .navigationTitle(Text("Change password", tableName: "Auth", bundle: .module)) + .interactiveDismissDisabled(ceremonies.isBusy).navigationBarBackButtonHidden(ceremonies.isBusy) + .onChange(of: ceremonies.state) { _, value in + if value == .completed(.passwordChanged) { current = ""; password = ""; confirmation = "" } + } + .onDisappear { + current = ""; password = ""; confirmation = ""; Task { await ceremonies.cancel() } + } + } + } + + public struct DeleteAccountView: View { + @Bindable private var ceremonies: AuthCeremonies + @Environment(\.scenePhase) private var scenePhase + @State private var confirmation = "" + @State private var code = "" + @State private var confirmingCode = false + public init(ceremonies: AuthCeremonies) { self.ceremonies = ceremonies } + public var body: some View { + List { + if ceremonies.state == .completed(.accountDeleted) { + Text( + "Your account was deleted. Maple is clearing this account’s local data.", tableName: "Auth", + bundle: .module + ) + .accessibilityIdentifier("auth.account-deleted") + } else { + Section { + Text( + "This permanently removes your account and its saved data. You will lose access to chats and paid features. This cannot be undone.", + tableName: "Auth", bundle: .module) + Text( + "Any required billing cleanup must finish before the account can be deleted.", + tableName: "Auth", bundle: .module + ).font(.callout) + if confirmingCode { + TextField( + text: $code, + prompt: Text("Account deletion code", tableName: "Auth", bundle: .module) + .foregroundStyle(MapleFieldPrompt()) + ) { Text("Account deletion code", tableName: "Auth", bundle: .module) } + .textContentType(.oneTimeCode).textInputAutocapitalization(.never) + .autocorrectionDisabled() + .accessibilityIdentifier("auth.delete-code") + Button(role: .destructive) { + Task { await ceremonies.confirmAccountDeletion(code: code) } + } label: { + Text("Confirm account deletion", tableName: "Auth", bundle: .module) + }.disabled(ceremonies.isBusy || code.isEmpty).accessibilityIdentifier("auth.confirm-delete") + } else { + TextField( + text: $confirmation, + prompt: Text("Type DELETE to confirm", tableName: "Auth", bundle: .module) + .foregroundStyle(MapleFieldPrompt()) + ) { Text("Type DELETE to confirm", tableName: "Auth", bundle: .module) } + .textInputAutocapitalization(.characters).autocorrectionDisabled().accessibilityIdentifier( + "auth.delete-confirmation") + Button(role: .destructive) { + Task { await ceremonies.requestAccountDeletion(confirmation: confirmation) } + } label: { + Text("Request deletion code", tableName: "Auth", bundle: .module) + }.disabled(ceremonies.isBusy || confirmation != "DELETE").accessibilityIdentifier( + "auth.request-delete") + } + }.disabled(ceremonies.isBusy) + } + if let issue = ceremonies.issue { AuthIssueView(issue: issue) } + } + .listStyle(.plain) + .listRowBackground(Color.clear) + .scrollContentBackground(.hidden) + .background(MapleSurface.canvas) + .navigationTitle(Text("Delete account", tableName: "Auth", bundle: .module)) + .interactiveDismissDisabled(ceremonies.isBusy).navigationBarBackButtonHidden(ceremonies.isBusy) + .onChange(of: ceremonies.state) { _, value in + if value == .deletionCode { confirmingCode = true } + if value == .idle { confirmingCode = false; code = "" } + } + .onChange(of: scenePhase) { _, phase in if phase == .active { ceremonies.expireIfNeeded() } } + .onDisappear { + code = ""; confirmation = ""; Task { await ceremonies.cancel() } + } + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Auth/AuthWorkflowFixtureView.swift b/apps/maple-ios/Sources/MapleAppUI/Auth/AuthWorkflowFixtureView.swift new file mode 100644 index 000000000..8d19e970d --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Auth/AuthWorkflowFixtureView.swift @@ -0,0 +1,400 @@ +#if os(iOS) && DEBUG + import Foundation + import MapleAppCore + import MapleSDK + import Observation + import SwiftUI + import Synchronization + import UIKit + + /// Exercises the shipping forms, session owner and native callback coordinator. + /// Only the remote account service and browser presentation are synthetic; + /// no fixture credentials, mail, provider requests or disk writes leave memory. + struct AuthWorkflowFixtureView: View { + @State private var model = AuthWorkflowFixtureModel() + + var body: some View { + VStack(spacing: 4) { + diagnostics + if let account = model.session.account { + NavigationStack { + List { + Text(account.user.email ?? account.user.id).accessibilityIdentifier("auth.fixture.account") + Text(account.user.loginMethod).accessibilityIdentifier("auth.fixture.method") + Button("Verify email") { model.open(.verification) } + .accessibilityIdentifier("auth.fixture.verify") + Button("Change password") { model.open(.changePassword) } + .accessibilityIdentifier("auth.fixture.change") + Button("Reset password") { model.open(.reset) } + .accessibilityIdentifier("auth.fixture.reset") + Button("Delete account") { model.open(.deletion) } + .accessibilityIdentifier("auth.fixture.delete") + if model.service.expiryFixture { + Button("Inject terminal refresh rejection") { + Task { await model.rejectRefresh() } + }.accessibilityIdentifier("auth.fixture.expire") + } + Button("Sign out") { Task { await model.session.signOut() } } + .accessibilityIdentifier("auth.fixture.sign-out") + }.navigationTitle("Synthetic account") + } + } else { + SignInView(session: model.session, coordinator: model.coordinator) { model.open(.reset) } + } + } + .task { await model.session.restore() } + .sheet(item: $model.screen) { screen in + NavigationStack { + Group { + switch screen.kind { + case .reset: PasswordResetView(ceremonies: screen.flow) + case .verification: EmailVerificationView(ceremonies: screen.flow) + case .changePassword: ChangePasswordView(ceremonies: screen.flow) + case .deletion: DeleteAccountView(ceremonies: screen.flow) + } + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { model.screen = nil }.accessibilityIdentifier("auth.fixture.close") + } + } + } + } + } + + private var diagnostics: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(model.phase).accessibilityIdentifier("auth.fixture.phase") + Text("Redemptions \(model.service.redemptions)").accessibilityIdentifier("auth.fixture.redemptions") + Text("Installs \(model.service.installs)").accessibilityIdentifier("auth.fixture.installs") + } + if !model.browser.presentations.isEmpty { + HStack { + Button("Deliver twice") { model.browser.deliver(duplicate: true) } + .accessibilityIdentifier("auth.fixture.callback-twice") + Button("Release") { model.service.releaseRedemption() } + .accessibilityIdentifier("auth.fixture.release") + Button("Wrong callback") { model.browser.deliverWrong() } + .accessibilityIdentifier("auth.fixture.wrong-callback") + } + HStack { + Button("Provider cancel") { model.browser.cancelFromProvider() } + .accessibilityIdentifier("auth.fixture.provider-cancel") + Button("Old callback") { model.browser.deliverPrevious() } + .accessibilityIdentifier("auth.fixture.old-callback") + Text(model.browser.validRequest ? "Local request valid" : "Invalid request") + .accessibilityIdentifier("auth.fixture.request") + } + } + Text("Reset requests \(model.service.resetRequests); confirmations \(model.service.resetConfirmations)") + .accessibilityIdentifier("auth.fixture.resets") + if model.service.expiryFixture { + Text( + "Revocations \(model.service.revocations); retirements \(model.authorityRetirements); signouts \(model.service.signOuts)" + ) + .accessibilityIdentifier("auth.fixture.authority") + Text(model.privateWork.isEmpty ? "Owned work cleared" : "Owned work retained") + .accessibilityIdentifier("auth.fixture.work") + if model.revocationFailed { + Text("Fixture revocation failed").accessibilityIdentifier("auth.fixture.revocation-error") + } + } + Text("Deletion fences \(model.deletionFences); cleanups \(model.deletionCleanups)") + .accessibilityIdentifier("auth.fixture.deletions") + }.font(.caption2).buttonStyle(.bordered).padding(.horizontal, 8) + } + } + + @MainActor @Observable private final class AuthWorkflowFixtureModel { + enum Kind { case reset, verification, changePassword, deletion } + struct Screen: Identifiable { let id = UUID(); let kind: Kind; let flow: AuthCeremonies } + let configuration: AppConfiguration + let service: AuthWorkflowFixtureService + let session: SessionController + let browser: AuthWorkflowFixtureBrowser + let coordinator: NativeSignInCoordinator + var screen: Screen? + var deletionFences = 0 + var deletionCleanups = 0 + var authorityRetirements = 0 + var privateWork = "Synthetic owned work" + var revocationFailed = false + + init() { + do { + configuration = try AppConfiguration( + profile: .local, apiURL: "http://127.0.0.1:1234", billingURL: "http://127.0.0.1:1235", + flagsURL: "http://127.0.0.1:1236", authURL: "http://127.0.0.1:1237", + clientID: "11111111-2222-4333-8444-555555555555", callbackScheme: "cloud.opensecret.maple.dev") + service = try AuthWorkflowFixtureService() + session = SessionController(service: service, storage: AuthWorkflowFixtureStorage()) + browser = AuthWorkflowFixtureBrowser() + let browser = browser + coordinator = NativeSignInCoordinator( + session: session, ceremonies: AuthCeremonies(service: service, configuration: configuration), + browserFactory: { url, scheme, _, completion in + browser.make(url: url, scheme: scheme, completion: completion) + }) + let coordinator = coordinator + session.onCancelExternal = { [weak coordinator] in await coordinator?.cancelPresentation() } + if service.expiryFixture { + session.onRetire = { [weak self] _ in + self?.authorityRetirements += 1 + self?.privateWork = "" + } + } + } catch { preconditionFailure("Invalid synthetic authentication fixture configuration") } + } + + var phase: String { + switch session.phase { + case .restoring: "restoring" + case .signedOut: "signedOut" + case .authenticating: "authenticating" + case .signedIn: "signedIn" + case .unavailable: "unavailable" + case .cleaningUp: "cleaningUp" + } + } + + func rejectRefresh() async { + do { try await service.rejectRefresh() } catch { revocationFailed = true } + } + + func open(_ kind: Kind) { + let flow = AuthCeremonies( + service: service, configuration: configuration, scope: session.account?.scope, + accountEmail: session.account?.user.email) + flow.beforeAccountDeletion = { [weak self] _ in self?.deletionFences += 1 } + flow.onAccountDeletionResult = { [weak self] _, deleted in + guard let self, deleted else { return } + self.deletionCleanups += 1 + await self.session.signOut() + } + flow.onPasswordResetResult = { [weak self] _ in await self?.session.signOut() } + screen = Screen(kind: kind, flow: flow) + } + } + + private actor AuthWorkflowFixtureStorage: AccountDataLifecycle { + func activate(_ scope: AccountScope) async throws {} + func retire(_ scope: AccountScope) async throws {} + func clearSignedOutData() async throws {} + } + + private final class AuthWorkflowFixtureSecureStore: SecureStore, Sendable { + private let values = Mutex<[String: Data]>([:]) + func read(key: String) throws -> Data? { values.withLock { $0[key] } } + func write(_ data: Data, key: String) throws { values.withLock { $0[key] = data } } + func remove(key: String) throws { values.withLock { _ = $0.removeValue(forKey: key) } } + } + + @MainActor @Observable private final class AuthWorkflowFixtureService: SessionServing, AuthCeremonyServing { + let authority: CredentialAuthority + let expiryFixture = ProcessInfo.processInfo.arguments.contains("--maple-auth-expiry-fixture") + var revocations = 0 + var signOuts = 0 + var redemptions = 0 + var installs = 0 + var resetRequests = 0 + var resetConfirmations = 0 + private var profile: MapleUser? + private var password = "fixture-password" + private var clockFailurePending = ProcessInfo.processInfo.arguments.contains("--maple-auth-clock-fixture") + private var resetHash: String? + private var resetEmail: String? + private var deletionHash: String? + private var redemption: CheckedContinuation? + + init() throws { + authority = try CredentialAuthority( + apiURL: "http://127.0.0.1:1234", store: AuthWorkflowFixtureSecureStore()) + } + func snapshot() async throws -> CredentialSnapshot { try await authority.snapshot() } + private func check(_ expected: CredentialSnapshot) async throws { + try Task.checkCancellation() + guard try await snapshot() == expected else { throw TransportError.authorityChanged } + } + func identity() async throws -> SessionIdentity? { + let snapshot = try await snapshot() + guard let principal = snapshot.principalId else { return nil } + return SessionIdentity(origin: snapshot.apiOrigin, principal: principal, generation: snapshot.generation) + } + func user() async throws -> MapleUser { + guard let profile else { throw ResearchAPIError.invalidResponse } + return profile + } + func signIn(email: String, password: String) async throws { + if clockFailurePending { + clockFailurePending = false + let signedAt = Date(timeIntervalSince1970: 1_800_000_000) + guard + let error = AttestationClockSkewError( + validatingDeviceTime: signedAt.addingTimeInterval(3 * 86_400), enclaveTime: signedAt, + certificateIndex: 0, notBefore: signedAt.addingTimeInterval(-600), + notAfter: signedAt.addingTimeInterval(3_600)) + else { throw ResearchAPIError.invalidResponse } + throw NSError(domain: "SyntheticAttestationTransport", code: 1, userInfo: [NSUnderlyingErrorKey: error]) + } + guard password == self.password else { throw ResearchAPIError.http(status: 401, reason: .invalidLogin) } + try await install(principal: "email-account", email: email, method: "email") + } + func signInGuest(id: String, password: String) async throws { + guard id == "existing-guest", password == self.password else { + throw ResearchAPIError.http(status: 401, reason: .invalidLogin) + } + try await install(principal: id, email: nil, method: "guest") + } + func signUp(email: String, password: String, name: String?) async throws { + guard password.utf16.count >= 8 else { throw ResearchAPIError.invalidArgument } + self.password = password + try await install(principal: "new-email-account", email: email, method: "email") + } + func signOut() async throws { signOuts += 1; try await authority.clear(); profile = nil } + func authorityChanges() async -> AsyncStream? { + guard expiryFixture else { return nil } + return await authority.changes() + } + func authorityState() async -> CredentialAuthorityChange? { + guard expiryFixture else { return nil } + return await authority.currentState() + } + func completePendingCleanup() async throws { try await authority.completePendingCleanup() } + func rejectRefresh() async throws { + guard expiryFixture else { throw ResearchAPIError.invalidArgument } + let expected = try await authority.snapshot() + guard expected.principalId != nil else { throw ResearchAPIError.invalidArgument } + // Inject only the terminal authentication decision. The real SDK + // authority publishes revocation; the shipping SessionController + // observer owns retirement and the visible signed-out transition. + try await authority.clear(expected: expected) + revocations += 1 + } + + func requestPasswordReset(email: String, hashedSecret: String, expected: CredentialSnapshot) async throws { + try await check(expected); resetHash = hashedSecret; resetEmail = email; resetRequests += 1 + } + func confirmPasswordReset( + email: String, code: String, plaintextSecret: String, newPassword: String, expected: CredentialSnapshot + ) async throws { + try await check(expected) + guard code == "ABCD1234", email == resetEmail, resetHash == hashSecret(plaintextSecret) else { + throw ResearchAPIError.invalidArgument + } + password = newPassword; resetConfirmations += 1; resetHash = nil; resetEmail = nil + } + func verifyEmail(code: String, expected: CredentialSnapshot) async throws { + try await check(expected) + guard UUID(uuidString: code) != nil else { throw ResearchAPIError.invalidArgument } + profile?.emailVerified = true + } + func resendVerification(expected: CredentialSnapshot) async throws { try await check(expected) } + func changePassword(currentPassword: String, newPassword: String, expected: CredentialSnapshot) async throws { + try await check(expected) + guard currentPassword == password else { throw ResearchAPIError.http(status: 401, reason: .invalidLogin) } + password = newPassword + } + func requestAccountDeletion(hashedSecret: String, expected: CredentialSnapshot) async throws { + try await check(expected); deletionHash = hashedSecret + } + func confirmAccountDeletion(code: String, plaintextSecret: String, expected: CredentialSnapshot) async throws { + try await check(expected) + guard UUID(uuidString: code) != nil, deletionHash == hashSecret(plaintextSecret) else { + throw ResearchAPIError.invalidArgument + } + try await authority.clear(expected: expected); profile = nil; deletionHash = nil + } + func signInWithApple(_ credential: AppleSignInCredential, rawNonce: String, expected: CredentialSnapshot) + async throws + { throw ResearchAPIError.invalidArgument } + + func prepareBrowser(id: UUID, expected: CredentialSnapshot) async throws -> BrowserHandoffReservation { + try await check(expected) + return BrowserHandoffReservation( + id: id, sessionID: String(repeating: "a", count: 32), requestID: String(repeating: "b", count: 32), + expiresAt: Date().addingTimeInterval(600)) + } + func redeemBrowser( + _ reservation: BrowserHandoffReservation, grant: NativeOAuthHandoffGrant, expected: CredentialSnapshot + ) async throws -> BrowserAccountConfirmation { + try await check(expected); redemptions += 1 + await withCheckedContinuation { redemption = $0 } + try await check(expected) + return BrowserAccountConfirmation( + id: reservation.id, principalID: "browser-account", email: "browser@example.test") + } + func confirmBrowser(_ confirmation: BrowserAccountConfirmation, expected: CredentialSnapshot) async throws { + try await check(expected) + try await install( + principal: confirmation.principalID, email: confirmation.email, method: "google", expected: expected) + installs += 1 + } + func cancelBrowser(id: UUID) async { releaseRedemption() } + func releaseRedemption() { let pending = redemption; redemption = nil; pending?.resume() } + + private func install(principal: String, email: String?, method: String, expected: CredentialSnapshot? = nil) + async throws + { + let captured: CredentialSnapshot + if let expected { captured = expected } else { captured = try await snapshot() } + _ = try await authority.install( + accessToken: token("access", principal: principal), + refreshToken: token("refresh", principal: principal), + expected: captured) + profile = MapleUser( + id: principal, email: email, emailVerified: false, loginMethod: method, createdAt: "", updatedAt: "") + } + private func token(_ kind: String, principal: String) throws -> String { + func encode(_ data: Data) -> String { + data.base64EncodedString().replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") + } + let body = try JSONSerialization.data(withJSONObject: [ + "sub": principal, "exp": 4_000_000_000, "tf": 2, + "aud": "urn:opensecret:internal:transport-v2:user:\(kind)-token", + ]) + return encode(Data("{}".utf8)) + "." + encode(body) + "." + encode(Data("fixture".utf8)) + } + } + + @MainActor @Observable private final class AuthWorkflowFixtureBrowser { + private(set) var presentations: [AuthWorkflowFixturePresentation] = [] + private(set) var validRequest = false + func make(url: URL, scheme: String, completion: @escaping BrowserSignInCompletion) + -> any BrowserSignInPresentation + { + let parts = URLComponents(url: url, resolvingAgainstBaseURL: false) + let query = parts?.queryItems ?? [] + validRequest = + url.host == "127.0.0.1" && url.path == "/desktop-auth" + && scheme == "cloud.opensecret.maple.dev" + && query.contains(where: { $0.name == "transport" && $0.value == "v2" }) + && query.contains(where: { $0.name == "native_app_variant" && $0.value == "dev" }) + let result = AuthWorkflowFixturePresentation(completion: completion) + presentations.append(result) + if presentations.count > 4 { presentations.removeFirst() } + return result + } + func deliver(duplicate: Bool) { + presentations.last?.deliver(callback) + if duplicate { presentations.last?.deliver(callback) } + } + func deliverWrong() { + presentations.last?.deliver(URL(string: "cloud.opensecret.maple://auth?handoff_grant=e30.e30.e30")!) + } + func deliverPrevious() { + guard presentations.count > 1 else { return } + presentations[presentations.count - 2].deliver(callback) + } + func cancelFromProvider() { presentations.last?.completion(nil, .cancelled) } + private var callback: URL { URL(string: "cloud.opensecret.maple.dev://auth?handoff_grant=e30.e30.e30")! } + } + @MainActor private final class AuthWorkflowFixturePresentation: BrowserSignInPresentation { + let completion: BrowserSignInCompletion + init(completion: @escaping BrowserSignInCompletion) { self.completion = completion } + func start() -> Bool { true } + func cancel() {} // A retained callback can still arrive; ownership must reject it. + func deliver(_ url: URL) { completion(url, nil) } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Auth/LocalAccountLinkFixture.swift b/apps/maple-ios/Sources/MapleAppUI/Auth/LocalAccountLinkFixture.swift new file mode 100644 index 000000000..04d7d2f1b --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Auth/LocalAccountLinkFixture.swift @@ -0,0 +1,42 @@ +#if os(iOS) && DEBUG + import Foundation + import MapleAppCore + + /// Finite delivery inputs for local root onOpenURL tests where an unsigned + /// loopback build has no associated-domain entitlement. It accepts no caller + /// destination, code, secret or account value, and is never a release route. + enum LocalAccountLinkFixture { + static func forward(_ incoming: URL, configuration: AppConfiguration) -> URL? { + guard configuration.profile == .local, incoming.absoluteString.utf8.count <= 4_096, + let input = URLComponents(url: incoming, resolvingAgainstBaseURL: false), + input.scheme == configuration.callbackScheme, input.host == "ui-test-account-link", + input.user == nil, input.password == nil, input.port == nil, + input.query == nil, input.fragment == nil, input.percentEncodedHost == input.host, + input.percentEncodedPath == input.path, + var result = URLComponents(url: configuration.authURL, resolvingAgainstBaseURL: false) + else { return nil } + switch input.path { + case "/verify": + result.path = "/verify/00000000-0000-4000-8000-000000000042" + result.queryItems = nil + case "/reset": + result.path = "/password-reset/confirm" + result.queryItems = [ + URLQueryItem(name: "code", value: "ABCD1234"), + URLQueryItem(name: "email", value: "synthetic-other@example.test"), + URLQueryItem(name: "secret", value: "synthetic-external-secret"), + ] + case "/foreign-reset": + result.port = (result.port ?? 80) == 1 ? 2 : 1 + result.path = "/password-reset"; result.queryItems = nil + case "/duplicate-reset": + result.path = "/password-reset" + result.queryItems = [ + URLQueryItem(name: "code", value: "ABCD1234"), URLQueryItem(name: "code", value: "EFGH5678"), + ] + default: return nil + } + return result.url + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Auth/NativeBrowserPresentation.swift b/apps/maple-ios/Sources/MapleAppUI/Auth/NativeBrowserPresentation.swift new file mode 100644 index 000000000..e8ccaece1 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Auth/NativeBrowserPresentation.swift @@ -0,0 +1,43 @@ +#if os(iOS) + import AuthenticationServices + import UIKit + + enum BrowserSignInPresentationError: Sendable, Equatable { case cancelled, failed } + typealias BrowserSignInCompletion = @MainActor @Sendable (URL?, BrowserSignInPresentationError?) -> Void + typealias BrowserSignInPresentationFactory = + @MainActor ( + URL, String, UIWindow, @escaping BrowserSignInCompletion + ) -> any BrowserSignInPresentation + + @MainActor protocol BrowserSignInPresentation: AnyObject { + func start() -> Bool + func cancel() + } + + /// The injectable seam owns presentation only. Credential redemption and + /// account confirmation always remain in the production coordinator/Core. + @MainActor + final class NativeBrowserPresentation: NSObject, BrowserSignInPresentation, + ASWebAuthenticationPresentationContextProviding + { + private let anchor: UIWindow + private let session: ASWebAuthenticationSession + + init(url: URL, callbackScheme: String, anchor: UIWindow, completion: @escaping BrowserSignInCompletion) { + self.anchor = anchor + session = ASWebAuthenticationSession(url: url, callback: .customScheme(callbackScheme)) { url, error in + let failure: BrowserSignInPresentationError? = error.map { + ($0 as? ASWebAuthenticationSessionError)?.code == .canceledLogin ? .cancelled : .failed + } + Task { @MainActor in completion(url, failure) } + } + super.init() + session.presentationContextProvider = self + session.prefersEphemeralWebBrowserSession = true + } + + func start() -> Bool { session.start() } + func cancel() { session.cancel() } + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { anchor } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Auth/NativeSignInCoordinator.swift b/apps/maple-ios/Sources/MapleAppUI/Auth/NativeSignInCoordinator.swift new file mode 100644 index 000000000..4f077ebb1 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Auth/NativeSignInCoordinator.swift @@ -0,0 +1,286 @@ +#if os(iOS) + import AuthenticationServices + import MapleAppCore + import MapleSDK + import Observation + import SwiftUI + import UIKit + + /// Owns exactly one native presentation and its SessionController ticket. + /// Neither browser completion nor a universal-link delivery installs before + /// the Core's explicit verified-account confirmation step. + @MainActor @Observable public final class NativeSignInCoordinator: NSObject { + public let ceremonies: AuthCeremonies + public private(set) var failure: AuthCeremonyIssue? + public private(set) var isRunning = false + @ObservationIgnored private let session: SessionController + @ObservationIgnored private weak var anchor: UIWindow? + @ObservationIgnored private var ticket: UUID? + @ObservationIgnored private var attemptID: UUID? + @ObservationIgnored private var appleController: ASAuthorizationController? + @ObservationIgnored private var applePresentationContext: AppleSignInPresentationContext? + @ObservationIgnored private var webSession: (any BrowserSignInPresentation)? + @ObservationIgnored private let browserFactory: BrowserSignInPresentationFactory + @ObservationIgnored private var browserCallbackClaimed = false + + public convenience init(session: SessionController, ceremonies: AuthCeremonies) { + self.init( + session: session, ceremonies: ceremonies, + browserFactory: { url, scheme, anchor, completion in + NativeBrowserPresentation(url: url, callbackScheme: scheme, anchor: anchor, completion: completion) + }) + } + + init( + session: SessionController, ceremonies: AuthCeremonies, + browserFactory: @escaping BrowserSignInPresentationFactory + ) { + self.session = session; self.ceremonies = ceremonies + self.browserFactory = browserFactory + super.init() + } + + public func attach(window: UIWindow?) { anchor = window } + + public func beginApple() async { + guard !isRunning, anchor != nil, let owned = session.beginExternalAuthentication() else { return } + ticket = owned; isRunning = true; failure = nil + guard let request = await ceremonies.beginAppleSignIn(), ticket == owned else { + await settleFailure(owned); return + } + guard let anchor, anchor.windowScene != nil else { await cancel(); return } + attemptID = request.id + let appleRequest = ASAuthorizationAppleIDProvider().createRequest() + appleRequest.requestedScopes = [.fullName, .email] + appleRequest.nonce = request.nonce; appleRequest.state = request.state + let controller = ASAuthorizationController(authorizationRequests: [appleRequest]) + let presentationContext = AppleSignInPresentationContext(anchor: anchor) + appleController = controller + applePresentationContext = presentationContext + controller.delegate = self; controller.presentationContextProvider = presentationContext + controller.performRequests() + } + + public func beginBrowser(provider: OAuthProvider) async { + guard !isRunning, anchor != nil, let owned = session.beginExternalAuthentication() else { return } + ticket = owned; isRunning = true; failure = nil + guard let request = await ceremonies.beginBrowserSignIn(provider: provider), ticket == owned else { + await settleFailure(owned); return + } + attemptID = request.id + browserCallbackClaimed = false + guard let anchor else { await cancel(); return } + let browser = browserFactory(request.url, request.callbackScheme, anchor) { + [weak self] url, error in + Task { @MainActor in + guard let self, self.ticket == owned, self.attemptID == request.id, + !self.browserCallbackClaimed + else { return } + self.webSession = nil + guard let url else { + if error != .cancelled { + self.failure = .connection + } + await self.cancel(); return + } + await self.receiveBrowser(url, id: request.id, ticket: owned) + } + } + webSession = browser + if !browser.start() { failure = .connection; await cancel() } + } + + /// Route a manual "Open Maple" callback only to a currently owned attempt. + @discardableResult public func receiveCallback(_ url: URL) async -> Bool { + guard let id = attemptID, let owned = ticket, case .browser = ceremonies.state else { return false } + await receiveBrowser(url, id: id, ticket: owned) + return true + } + + private func receiveBrowser(_ url: URL, id: UUID, ticket owned: UUID) async { + guard ticket == owned, attemptID == id, !browserCallbackClaimed else { return } + browserCallbackClaimed = true + let presentation = webSession; webSession = nil + presentation?.cancel() + await ceremonies.redeemBrowserCallback(id: id, url: url) + guard ticket == owned else { return } + if case .confirmBrowser = ceremonies.state { return } + await settleFailure(owned) + } + + public func confirmBrowser() async { + guard let owned = ticket, case .confirmBrowser(let account) = ceremonies.state else { return } + await ceremonies.confirmBrowserAccount(id: account.id) + await finish(owned) + } + + public func cancel() async { + guard let owned = ticket else { await cancelPresentation(); return } + await cancelPresentation() + await session.cancelExternalAuthentication(owned) + } + + /// Wire to SessionController.onCancelExternal. This does not call back into + /// SessionController, so sign-out and cancellation cannot recurse. + public func cancelPresentation() async { + ticket = nil; attemptID = nil; isRunning = false + browserCallbackClaimed = false + let apple = appleController, browser = webSession + appleController = nil; webSession = nil + apple?.cancel(); browser?.cancel() + applePresentationContext = nil + await ceremonies.cancel() + } + + public func foreground() async { + ceremonies.expireIfNeeded() + if ceremonies.issue == .expired, let owned = ticket { await settleFailure(owned) } + } + + private func finish(_ owned: UUID) async { + guard ticket == owned else { return } + if ceremonies.state == .completed(.signedIn) { + ticket = nil; attemptID = nil; isRunning = false + appleController = nil; webSession = nil + applePresentationContext = nil + await session.finishExternalAuthentication(owned) + } else { + await settleFailure(owned) + } + } + + private func settleFailure(_ owned: UUID) async { + guard ticket == owned else { return } + failure = ceremonies.issue ?? failure + await cancel() + } + } + + @MainActor + private final class AppleSignInPresentationContext: NSObject, + ASAuthorizationControllerPresentationContextProviding + { + private let anchor: UIWindow + + init(anchor: UIWindow) { self.anchor = anchor } + + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { anchor } + } + + extension NativeSignInCoordinator: ASAuthorizationControllerDelegate { + public func authorizationController( + controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization + ) { + guard controller === appleController, let owned = ticket, let id = attemptID else { return } + appleController = nil + applePresentationContext = nil + guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential, + let data = credential.identityToken, let token = String(data: data, encoding: .utf8) + else { + failure = .invalidCallback + Task { await settleFailure(owned) }; return + } + let result = AppleSignInCredential( + identityToken: token, userIdentifier: credential.user, state: credential.state, + email: credential.email, givenName: credential.fullName?.givenName, + familyName: credential.fullName?.familyName) + Task { + guard ticket == owned else { return } + await ceremonies.completeAppleSignIn(id: id, credential: result) + await finish(owned) + } + } + public func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { + guard controller === appleController, let owned = ticket else { return } + if (error as? ASAuthorizationError)?.code != .canceled { failure = .connection } + appleController = nil + applePresentationContext = nil + Task { await settleFailure(owned) } + } + } + + public struct NativeSignInButtons: View { + @Bindable private var coordinator: NativeSignInCoordinator + @Environment(\.scenePhase) private var scenePhase + @Environment(\.colorScheme) private var colorScheme + public init(coordinator: NativeSignInCoordinator) { self.coordinator = coordinator } + public var body: some View { + VStack(spacing: 12) { + NativeAppleSignInButton { Task { await coordinator.beginApple() } } + .id(colorScheme) + .frame(height: 48).disabled(coordinator.isRunning).accessibilityIdentifier("auth.apple") + Button { + Task { await coordinator.beginBrowser(provider: .google) } + } label: { + Text("Continue with Google", tableName: "Auth", bundle: .module).frame(maxWidth: .infinity) + }.buttonStyle(.bordered).disabled(coordinator.isRunning).accessibilityIdentifier("auth.google") + Button { + Task { await coordinator.beginBrowser(provider: .github) } + } label: { + Text("Continue with GitHub", tableName: "Auth", bundle: .module).frame(maxWidth: .infinity) + }.buttonStyle(.bordered).disabled(coordinator.isRunning).accessibilityIdentifier("auth.github") + if case .confirmBrowser(let account) = coordinator.ceremonies.state { + VStack(alignment: .leading, spacing: 12) { + Text("Confirm account", tableName: "Auth", bundle: .module).font(.headline) + Text(account.email ?? account.principalID).textSelection(.enabled) + Text( + "Only continue if this is the account you intended to use.", tableName: "Auth", + bundle: .module) + Button { + Task { await coordinator.confirmBrowser() } + } label: { + Text("Continue with this account", tableName: "Auth", bundle: .module) + }.buttonStyle(MaplePrimaryButtonStyle()) + .accessibilityIdentifier("auth.confirm-account") + }.padding().background(.quaternary, in: RoundedRectangle(cornerRadius: 12)) + } + if coordinator.isRunning { + Button(role: .cancel) { + Task { await coordinator.cancel() } + } label: { + Text("Cancel sign-in", tableName: "Auth", bundle: .module) + }.accessibilityIdentifier("auth.cancel-provider") + } + if let failure = coordinator.failure { AuthIssueView(issue: failure) } + } + .background(AuthPresentationAnchor { coordinator.attach(window: $0) }.frame(width: 0, height: 0)) + .onChange(of: scenePhase) { _, value in if value == .active { Task { await coordinator.foreground() } } } + .onChange(of: coordinator.ceremonies.issue) { _, issue in + if issue == .expired { Task { await coordinator.foreground() } } + } + } + } + + private struct NativeAppleSignInButton: UIViewRepresentable { + var action: @MainActor () -> Void + @Environment(\.colorScheme) private var colorScheme + @Environment(\.isEnabled) private var isEnabled + func makeCoordinator() -> Target { Target(action: action) } + func makeUIView(context: Context) -> ASAuthorizationAppleIDButton { + let button = ASAuthorizationAppleIDButton(type: .signIn, style: colorScheme == .dark ? .white : .black) + button.addTarget(context.coordinator, action: #selector(Target.activate), for: .touchUpInside) + button.cornerRadius = 8 + return button + } + func updateUIView(_ uiView: ASAuthorizationAppleIDButton, context: Context) { + context.coordinator.action = action + uiView.isEnabled = isEnabled + } + @MainActor final class Target: NSObject { + var action: @MainActor () -> Void + init(action: @escaping @MainActor () -> Void) { self.action = action } + @objc func activate() { action() } + } + } + private struct AuthPresentationAnchor: UIViewRepresentable { + let onWindow: @MainActor (UIWindow?) -> Void + func makeUIView(context: Context) -> AnchorView { + let view = AnchorView(); view.onWindow = onWindow; return view + } + func updateUIView(_ uiView: AnchorView, context: Context) { uiView.onWindow = onWindow } + @MainActor final class AnchorView: UIView { + var onWindow: (@MainActor (UIWindow?) -> Void)? + override func didMoveToWindow() { super.didMoveToWindow(); onWindow?(window) } + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Auth/SignInView.swift b/apps/maple-ios/Sources/MapleAppUI/Auth/SignInView.swift new file mode 100644 index 000000000..1c238ed36 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Auth/SignInView.swift @@ -0,0 +1,155 @@ +#if os(iOS) + import MapleAppCore + import SwiftUI + + struct SignInView: View { + private enum Mode: String, CaseIterable { case email, signup, guest } + @Bindable var session: SessionController + let coordinator: NativeSignInCoordinator + let onPasswordReset: () -> Void + @State private var mode = Mode.email + @State private var email = "" + @State private var password = "" + @State private var externalLink: URL? + @FocusState private var focused: Field? + private enum Field { case email, password } + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + MapleWordmark() + .frame(width: 164, height: 32) + .accessibilityLabel(MapleStrings.text("Maple")) + .accessibilityAddTraits(.isHeader) + Text( + mode == .signup + ? MapleStrings.text("Create your account") : MapleStrings.text("Welcome back") + ) + .font(MapleTypography.title) + VStack(spacing: 16) { + TextField( + mode == .guest ? MapleStrings.text("Guest account ID") : MapleStrings.text("Email"), + text: $email, + prompt: Text( + mode == .guest ? MapleStrings.text("Guest account ID") : MapleStrings.text("Email") + ).foregroundStyle(MapleFieldPrompt()) + ) + .textContentType(mode == .guest ? .username : .emailAddress) + .keyboardType(mode == .guest ? .asciiCapable : .emailAddress) + .textInputAutocapitalization(.never).autocorrectionDisabled() + .focused($focused, equals: .email).submitLabel(.next) + .onSubmit { focused = .password }.accessibilityIdentifier("auth.identifier") + SecureField( + MapleStrings.text("Password"), text: $password, + prompt: Text(MapleStrings.text("Password")).foregroundStyle(MapleFieldPrompt()) + ) + .textContentType(mode == .signup ? .newPassword : .password) + .focused($focused, equals: .password).submitLabel(.go) + .onSubmit { submit() }.accessibilityIdentifier("auth.password") + } + .textFieldStyle(.roundedBorder) + if mode == .signup { + Text("Use at least eight characters.", tableName: "Auth", bundle: .module) + .font(.footnote).foregroundStyle(.secondary) + } + if let issue = session.issue { + Text(message(for: issue)).foregroundStyle(MapleRedText()).accessibilityIdentifier( + "auth.error") + } + Button(action: submit) { + HStack { + Text( + mode == .signup ? MapleStrings.text("Create account") : MapleStrings.text("Sign in") + ) + }.frame(maxWidth: .infinity).padding(.vertical, 6) + } + .buttonStyle(MaplePrimaryButtonStyle()) + .disabled(!canSubmit).accessibilityIdentifier("auth.submit") + if mode != .guest { + Button(MapleStrings.text("Forgot password?"), action: onPasswordReset) + .disabled(session.phase != .signedOut).accessibilityIdentifier("auth.forgot") + NativeSignInButtons(coordinator: coordinator) + .disabled(session.phase != .signedOut && !coordinator.isRunning) + } + Button( + mode == .signup + ? MapleStrings.text("Already have an account? Sign in") + : MapleStrings.text("Create an account") + ) { + mode = mode == .signup ? .email : .signup; password = "" + }.disabled(session.phase == .authenticating) + Button( + mode == .guest + ? MapleStrings.text("Sign in with email") + : MapleStrings.text("Use an existing guest account") + ) { + mode = mode == .guest ? .email : .guest; email = ""; password = "" + }.disabled(session.phase == .authenticating) + VStack(spacing: 8) { + Text( + String( + localized: "auth.copyright", + defaultValue: + "© \(String(Calendar(identifier: .gregorian).component(.year, from: Date()))) Maple Privacy Labs Inc.", + table: "Auth", bundle: .module) + ) + .foregroundStyle(.secondary) + HStack(spacing: 24) { + Button { + externalLink = URL(string: "https://trymaple.ai/terms") + } label: { + Text("Terms", tableName: "Auth", bundle: .module) + .frame(minWidth: 44, minHeight: 44) + }.accessibilityIdentifier("auth.terms") + Button { + externalLink = URL(string: "https://trymaple.ai/privacy") + } label: { + Text("Privacy", tableName: "Auth", bundle: .module) + .frame(minWidth: 44, minHeight: 44) + }.accessibilityIdentifier("auth.privacy") + } + } + .font(.footnote).frame(maxWidth: .infinity) + } + .padding(28).frame(maxWidth: 480).frame(maxWidth: .infinity) + } + .scrollDismissesKeyboard(.interactively) + } + .externalLinks($externalLink) + } + + private var canSubmit: Bool { + session.phase == .signedOut && !email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !password.isEmpty && (mode != .signup || AuthCeremonies.validPassword(password)) + } + + private func submit() { + guard canSubmit else { return } + focused = nil + let submittedID = email.trimmingCharacters(in: .whitespacesAndNewlines) + let submittedPassword = password + Task { + switch mode { + case .email: await session.signIn(email: submittedID, password: submittedPassword) + case .guest: await session.signInGuest(id: submittedID, password: submittedPassword) + case .signup: + await session.signUp(email: submittedID, password: submittedPassword) + } + password = "" + } + } + + private func message(for issue: SessionIssue) -> String { + switch issue { + case .clock(let clock): AttestationClockText.message(clock) + case .credentials: MapleStrings.text("The account details were not accepted. Try again.") + case .emailAlreadyRegistered: MapleStrings.text("An account already uses this email address.") + case .invalidInput: MapleStrings.text("Check the account details and try again.") + case .connection: MapleStrings.text("We couldn’t connect. Check your connection and try again.") + case .storage, .cleanup: + MapleStrings.text("Protected account storage is unavailable. Try again after unlocking.") + } + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Chat/AccountAccessNotice.swift b/apps/maple-ios/Sources/MapleAppUI/Chat/AccountAccessNotice.swift new file mode 100644 index 000000000..bf0af7df3 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Chat/AccountAccessNotice.swift @@ -0,0 +1,76 @@ +#if os(iOS) + import MapleAppCore + import SwiftUI + + struct AccountAccessNotice: View { + @Bindable var account: AccountEntitlements + var body: some View { + if !awaitingDetails && account.chatAccess != .allowed && account.chatAccess != .seatMismatch { + HStack(alignment: .top) { + Text(message).font(.footnote).foregroundStyle(.secondary) + if account.chatAccess == .unavailable || account.chatAccess == .unknownPlan + || (account.chatAccess == .modelUnavailable && account.catalog.value == nil) + { + Button(MapleStrings.text("Retry")) { Task { await account.refresh() } }.font(.footnote) + } + } + } + } + private var awaitingDetails: Bool { + switch account.chatAccess { + case .unavailable: account.billing == .unknown || account.billing == .loading + case .modelUnavailable: account.catalog == .unknown || account.catalog == .loading + default: false + } + } + private var message: String { + switch account.chatAccess { + case .allowed: return "" + case .usageLimit: return MapleStrings.text("You’ve reached your current chat allowance.") + case .freeGuest: + return MapleStrings.text("This guest account can’t start a chat. Sign in with an eligible account.") + case .seatMismatch: return "" + case .paidPlanRequired: + return MapleStrings.text("This model requires a paid plan. Choose an available model.") + case .modelUnavailable, .modelIncompatible: + return MapleStrings.text("The selected model isn’t currently available.") + case .retired: return MapleStrings.text("Your session has ended.") + case .unknownPlan, .unavailable: + return MapleStrings.text("Account access is unavailable. Your draft is preserved.") + } + } + } + + /// Read-only status shared by the chat routes. Team management and purchases + /// remain outside this presentation; the server owns the usage restriction. + struct TeamSeatMismatchNotice: View { + @Bindable var account: AccountEntitlements + @Environment(\.locale) private var locale + + var body: some View { + if let presentation = TeamSeatMismatchPresentation(status: account.team.value) { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.red).accessibilityHidden(true) + VStack(alignment: .leading, spacing: 4) { + Text(MapleStrings.text("Team usage paused", locale: locale)) + .font(.subheadline.weight(.medium)).accessibilityAddTraits(.isHeader) + Text(presentation.summary(locale: locale)) + .font(.caption.weight(.medium)).foregroundStyle(.red) + Text(presentation.message(locale: locale)) + .font(.caption).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(16) + .background(MapleSurface.card, in: RoundedRectangle(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8).strokeBorder(.red.opacity(0.5), lineWidth: 1) + } + .padding(.horizontal, 12).padding(.vertical, 8) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("chat.team-seat-mismatch") + } + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Chat/ChatLegacyContent.swift b/apps/maple-ios/Sources/MapleAppUI/Chat/ChatLegacyContent.swift new file mode 100644 index 000000000..aeb0f95ea --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Chat/ChatLegacyContent.swift @@ -0,0 +1,148 @@ +import Foundation + +/// Presentation-only compatibility with Research's stored document and think +/// tags. Invalid/incomplete documents keep their original selectable text. +enum ChatLegacyContent { + struct Source: Equatable, Sendable { + let text: String + let user: Bool + let assistant: Bool + let streaming: Bool + let generation: UUID + + var requiresPreparation: Bool { assistant || (user && text.contains("{\"document\":")) } + } + + struct Prepared: Equatable, Sendable { + let source: Source + let document: Document? + let parts: [Part] + } + + enum Presentation: Equatable, Sendable { + case ordinary, document(Document), assistant([Part]), pendingDocument, pendingThinking + } + + struct Document: Equatable, Sendable { + let filename: String + let text: String + let before: String + let after: String + } + + struct Part: Equatable, Identifiable, Sendable { + enum Kind: Equatable, Sendable { case text, thinking } + let id: Int + let kind: Kind + let text: String + let isThinking: Bool + } + + static func prepare(_ source: Source) -> Prepared { + Prepared( + source: source, document: source.user ? document(in: source.text) : nil, + parts: source.assistant ? assistantParts(source.text, streaming: source.streaming) : []) + } + + /// Pending compatibility work must not expose document JSON or opening + /// think tags as ordinary text. Streaming appends may keep their last rich + /// presentation, but only an exact source can publish preparation receipts. + static func presentation(source: Source, prepared: Prepared?) -> Presentation { + guard source.requiresPreparation else { return .ordinary } + if let prepared, prepared.source == source { + if source.assistant { return .assistant(prepared.parts) } + return prepared.document.map(Presentation.document) ?? .ordinary + } + if source.assistant { + if let prepared, prepared.source.assistant, prepared.source.streaming, source.streaming, + prepared.source.generation == source.generation, source.text.hasPrefix(prepared.source.text) + { + return .assistant(prepared.parts) + } + return .pendingThinking + } + return .pendingDocument + } + + static func document(in text: String) -> Document? { + guard let start = text.range(of: "{\"document\":"), text.utf8.count <= 1_048_576 else { return nil } + var depth = 0, inString = false, escaped = false + let scalars = text.unicodeScalars + for index in scalars[start.lowerBound...].indices { + let character = scalars[index] + if escaped { escaped = false; continue } + if character == "\\" { escaped = true; continue } + if character == "\"" { inString.toggle(); continue } + guard !inString else { continue } + if character == "{" { depth += 1 } + if character == "}" { + depth -= 1 + guard depth == 0 else { continue } + let end = scalars.index(after: index) + struct Payload: Decodable { + struct Document: Decodable { + let filename: String + let textContent: String + enum CodingKeys: String, CodingKey { case filename, textContent = "text_content" } + } + let document: Document + } + guard + let value = try? JSONDecoder().decode( + Payload.self, from: Data(text[start.lowerBound.. [Part] { + // Research recovers a completed response whose standalone opening tag + // never closed by showing the following answer as ordinary content. + if !streaming, !text.contains(""), + let emptyOpening = try? NSRegularExpression(pattern: "^\\s*$", options: .anchorsMatchLines), + emptyOpening.firstMatch(in: text, range: NSRange(location: 0, length: (text as NSString).length)) != nil, + let stripping = try? NSRegularExpression(pattern: "^\\s*", options: .anchorsMatchLines) + { + guard + let match = stripping.firstMatch( + in: text, + range: NSRange(location: 0, length: (text as NSString).length)), + let range = Range(match.range, in: text) + else { return [] } + var value = text + value.removeSubrange(range) + return value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? [] : [Part(id: 0, kind: .text, text: value, isThinking: false)] + } + let source = text as NSString + guard let pattern = try? NSRegularExpression(pattern: "([\\s\\S]*?)(?:|$)") else { return [] } + var parts: [Part] = [], previousEnd = 0 + let matches = pattern.matches(in: text, range: NSRange(location: 0, length: source.length)) + func appendText(_ range: NSRange) { + let value = source.substring(with: range) + guard !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + parts.append(Part(id: range.location, kind: .text, text: value, isThinking: false)) + } + for match in matches { + if match.range.location > previousEnd { + appendText(NSRange(location: previousEnd, length: match.range.location - previousEnd)) + } + let value = source.substring(with: match.range(at: 1)) + if streaming || !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + let closed = source.substring(with: match.range).hasSuffix("") + parts.append( + Part(id: match.range.location, kind: .thinking, text: value, isThinking: streaming && !closed)) + } + previousEnd = NSMaxRange(match.range) + } + if previousEnd < source.length { + appendText(NSRange(location: previousEnd, length: source.length - previousEnd)) + } + return parts + } +} diff --git a/apps/maple-ios/Sources/MapleAppUI/Chat/ChatOptionsSheet.swift b/apps/maple-ios/Sources/MapleAppUI/Chat/ChatOptionsSheet.swift new file mode 100644 index 000000000..5b04aada8 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Chat/ChatOptionsSheet.swift @@ -0,0 +1,204 @@ +#if os(iOS) + import MapleAppCore + import SwiftUI + import UIKit + + /// Delegate to the platform sizing from the presenter's width, keeping one + /// presentation type while project navigation changes the sheet contents. + struct AdaptiveChatSheetSizing: PresentationSizing { + let regularWidth: Bool + + func proposedSize( + for root: PresentationSizingRoot, + context: PresentationSizingContext + ) -> ProposedViewSize { + let page = PagePresentationSizing.page.proposedSize(for: root, context: context) + if regularWidth { + let form = FormPresentationSizing.form.proposedSize(for: root, context: context) + // Retain the native form width and outside space while giving + // the scrollable model catalog the native page's vertical room. + return ProposedViewSize(width: form.width, height: page.height) + } + return page + } + } + + /// One native sheet owns the model, web-search and project choices. Project + /// navigation uses this stack instead of presenting another picker sheet. + struct ChatOptionsSheet: View { + @Bindable var entitlements: AccountEntitlements + @Bindable var preferences: PreferencesController + @Bindable var library: LibraryModel + let regularWidth: Bool + let selectedProjectID: String? + let makeInstructionEditor: @MainActor (InstructionDestination) -> InstructionEditor + let isCurrent: @MainActor () -> Bool + let onSelectProject: @MainActor (String?) async -> Bool + let onSelectModel: @MainActor (String) -> Void + @Environment(\.dismiss) private var dismiss + @State private var path: [ProjectSheetDestination] = [] + @State private var selectedDetent: PresentationDetent = .fraction(0.6) + + var body: some View { + NavigationStack(path: $path) { + SettingsModelPicker( + entitlements: entitlements, + webSearchPreference: Binding( + get: { preferences.value.webSearchPreferred }, + set: { if isCurrent() { preferences.setWebSearchPreferred($0) } }), + webSearchPreferences: preferences, + projectRow: AnyView(projectRow), topContentMargin: 40, canInteract: isCurrent + ) { id in + guard isCurrent() else { return } + onSelectModel(id) + } + .ignoresSafeArea(.container, edges: .top) + .background( + OptionsRootNavigationBarPassThrough(active: path.isEmpty && isCurrent(), isCurrent: isCurrent) + .frame(width: 0, height: 0) + ) + .navigationBarTitleDisplayMode(.inline) + .toolbarVisibility(.visible, for: .navigationBar) + .toolbarBackground(.hidden, for: .navigationBar) + .navigationDestination(for: ProjectSheetDestination.self) { destination in + ProjectSheetDestinationView( + destination: destination, library: library, selectedProjectID: selectedProjectID, + makeInstructionEditor: makeInstructionEditor, isCurrent: isCurrent, + onSelection: selectProject) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .presentationDetents([.fraction(0.6), .fraction(0.98)], selection: $selectedDetent) + .presentationDragIndicator(.visible) + .presentationContentInteraction(.resizes) + .presentationBackgroundInteraction(.disabled) + .presentationSizing(AdaptiveChatSheetSizing(regularWidth: regularWidth)) + .presentationBackground(MapleSurface.canvas) + .background( + SheetOutsideTapDismissal { + // Editors retain their discard and in-flight safeguards. + if (path.last?.permitsOutsideDismissal ?? true), !library.isMutating { dismiss() } + }.frame(width: 0, height: 0) + ) + .accessibilityIdentifier("chat.options-sheet") + } + + private var projectRow: some View { + NavigationLink(value: ProjectSheetDestination.projects) { + HStack(spacing: 12) { + Image(systemName: "folder").foregroundStyle(MapleAccent.foreground) + libraryText("Project").foregroundStyle(.primary) + Spacer(minLength: 8) + if let project = library.projects.first(where: { $0.id == selectedProjectID }) { + Text(verbatim: project.name).foregroundStyle(.secondary).lineLimit(1) + } else { + libraryText("No project").foregroundStyle(.secondary) + } + } + .font(MapleTypography.button) + .frame(maxWidth: .infinity, minHeight: 44) + .contentShape(Rectangle()) + } + .disabled(!isCurrent() || library.isMutating) + .accessibilityIdentifier("chat-options.project") + } + + private func selectProject(_ id: String?) async -> Bool { + guard isCurrent(), await onSelectProject(id), isCurrent() else { return false } + path = []; return true + } + } + + /// Keep native navigation-bar geometry stable while the options root has + /// no bar controls. Its content can use that space without losing taps. + private struct OptionsRootNavigationBarPassThrough: UIViewControllerRepresentable { + let active: Bool + let isCurrent: @MainActor () -> Bool + + func makeUIViewController(context: Context) -> Controller { + Controller(active: active, isCurrent: isCurrent) + } + + func updateUIViewController(_ controller: Controller, context: Context) { + controller.active = active + controller.isCurrent = isCurrent + controller.updateInteraction() + } + + static func dismantleUIViewController(_ controller: Controller, coordinator: ()) { + controller.restoreInteraction() + } + + final class Controller: UIViewController { + var active: Bool + var isCurrent: @MainActor () -> Bool + private var visible = false + private weak var ownedNavigation: UINavigationController? + private weak var ownedBar: UINavigationBar? + private var previousInteraction: Bool? + + init(active: Bool, isCurrent: @escaping @MainActor () -> Bool) { + self.active = active; self.isCurrent = isCurrent + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is unavailable") } + + override func loadView() { + let observer = UIView() + observer.backgroundColor = .clear + observer.isUserInteractionEnabled = false + view = observer + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + visible = true; updateInteraction() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + updateInteraction() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + visible = false; restoreInteraction() + } + + func updateInteraction() { + guard visible, active, isCurrent(), let navigation = owningNavigationController() else { + restoreInteraction(); return + } + let bar = navigation.navigationBar + if ownedNavigation !== navigation || ownedBar !== bar { + restoreInteraction() + ownedNavigation = navigation; ownedBar = bar + previousInteraction = bar.isUserInteractionEnabled + } + bar.isUserInteractionEnabled = false + } + + func restoreInteraction() { + if let navigation = ownedNavigation, let bar = ownedBar, + navigation.navigationBar === bar, + let previousInteraction + { + bar.isUserInteractionEnabled = previousInteraction + } + ownedNavigation = nil; ownedBar = nil; previousInteraction = nil + } + + private func owningNavigationController() -> UINavigationController? { + var candidate: UIViewController? = self + while let controller = candidate { + if let navigation = controller as? UINavigationController { return navigation } + if let navigation = controller.navigationController { return navigation } + candidate = controller.parent + } + return nil + } + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Chat/ChatRecoveryControls.swift b/apps/maple-ios/Sources/MapleAppUI/Chat/ChatRecoveryControls.swift new file mode 100644 index 000000000..131a60598 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Chat/ChatRecoveryControls.swift @@ -0,0 +1,95 @@ +import Foundation +import MapleAppCore + +/// Read-only chat feedback copied from the Research client. +enum ChatRecoveryPresentation { + static func message(_ issue: ChatIssue, productName: String? = nil) -> String { + switch issue { + case .offline: + return MapleStrings.text("You’re offline. Your draft is saved when protected storage is available.") + case .storageUnavailable: + return MapleStrings.text("Your draft hasn’t been saved. Unlock your device and try again.") + case .uncertainResponse: + return MapleStrings.text("The response is still being checked. Your message will not be sent again.") + case .cancellationUnavailable: + return MapleStrings.text("Stop hasn’t been confirmed. Check the response to try again.") + case .usageLimit: + let plan = productName?.lowercased() + if plan == nil || plan?.isEmpty == true || plan == "free" { + return MapleStrings.text("You've reached your daily usage limit. Upgrade to Pro for more chats.") + } else if plan?.contains("pro") == true, plan?.contains("max") == false { + return MapleStrings.text("You've reached your monthly Pro limit. Upgrade to Max for 10x more usage.") + } else { + return MapleStrings.text( + "You've reached your monthly usage limit. Please wait for the next billing cycle.") + } + case .freeTierTokenLimit: + return MapleStrings.text( + "This conversation is too long for the free tier. Upgrade to Pro for longer conversations.") + case .contextLimit: return MapleStrings.text("Your message exceeds the context limit for this model.") + case .modelUnavailable: return MapleStrings.text("This model or feature is not available on your current plan.") + case .generationFailed: return MapleStrings.text("Failed to generate response. Please try again.") + case .stoppedBeforeSending: return MapleStrings.text("Stopped before sending. Your message was restored.") + case .stoppingWhenResponseReady: return MapleStrings.text("Stopping as soon as the response is ready…") + case .responseFinishedBeforeStop: return MapleStrings.text("The response finished before it could be stopped.") + case .recoveredCompletion: + return MapleStrings.text( + "The response completed after the connection was restored. Queued messages were kept.") + case .recoveredStop: return MapleStrings.text("The interrupted response stopped. Queued messages were kept.") + case .imageDescriptionUnavailable: + return MapleStrings.text( + "Image description is temporarily unavailable. Your message and images were restored; please try again." + ) + case .inferenceCapacityUnavailable: + return MapleStrings.text( + "Inference capacity is temporarily unavailable. Your message was restored. Please try again shortly.") + case .historyUnavailable: return MapleStrings.text("Couldn’t load the conversation. Try again when connected.") + case .invalidImages: return MapleStrings.text("Check the image attachments and try again.") + case .draftTooLarge: return MapleStrings.text("This message is too large. Shorten it and try again.") + case .signedOut: return MapleStrings.text("Your session has ended. Sign in again.") + case .conversationDeleting: return deletionPauseMessage + default: return MapleStrings.text("The request couldn’t finish. Your unsent work is preserved.") + } + } + + static var deletionPauseMessage: String { + String( + localized: "chat_issue_conversation_deleting", + defaultValue: "Sending paused because this conversation is being deleted.", + table: "Localizable", bundle: .module) + } + + static var contextLimitMessage: String { + MapleStrings.text( + "Your message exceeds the context limit for the current model.\n\nHere's what you can try:\nShorten your message - Try reducing the amount of text or content" + ) + } +} + +#if os(iOS) + import SwiftUI + + /// Shared shipping presentation for recoverable account-owned chat work. + struct ChatRecoveryControls: View { + @Bindable var runtime: ChatRuntime + var productName: String? = nil + var body: some View { + if let issue = runtime.lastIssue { + Text(ChatRecoveryPresentation.message(issue, productName: productName)) + .font(.footnote).foregroundStyle(MapleRedText()) + .accessibilityIdentifier("chat.error") + } + if runtime.phase == .recovering, runtime.lastIssue != nil { + Button(MapleStrings.text("Check response"), systemImage: "arrow.clockwise") { + Task { await runtime.reconcile() } + } + .labelStyle(.iconOnly).frame(minWidth: 44, minHeight: 44) + .accessibilityIdentifier("chat.recover") + } + if runtime.snapshot.unsentTurn != nil { + Button(MapleStrings.text("Restore unsent message")) { runtime.restoreUnsentTurn() } + .frame(minHeight: 44) + } + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Chat/ChatScreen.swift b/apps/maple-ios/Sources/MapleAppUI/Chat/ChatScreen.swift new file mode 100644 index 000000000..4383e8d2c --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Chat/ChatScreen.swift @@ -0,0 +1,2093 @@ +import Foundation +import MapleAppCore +import MapleSDK + +struct ChatTranscriptRow: Identifiable { + let item: ConversationItem + let toolResult: ConversationItem? + let order: Int + var id: String { item.id } + + // A previously standalone output can be absorbed when pagination reveals + // its call. Both persisted IDs must keep referring to the rendered row. + var historyAnchorIDs: [String] { + guard let toolResult, toolResult.id != item.id else { return [item.id] } + return [item.id, toolResult.id] + } + + // Conversation pages use function_call/function_call_output, while a + // retrieved Responses run uses tool_call/tool_output. Keep presentation + // admission consistent for both SDK-decoded representations. + static func isToolCall(_ item: ConversationItem) -> Bool { + ["function_call", "tool_call", "web_search_call"].contains(item.type) + } + static func isToolOutput(_ item: ConversationItem) -> Bool { + ["function_call_output", "tool_output"].contains(item.type) + } + static func isTool(_ item: ConversationItem) -> Bool { isToolCall(item) || isToolOutput(item) } + + static func rows(_ items: [ConversationItem]) -> [Self] { + let calls = Dictionary( + grouping: items.indices.filter { + isToolCall(items[$0]) && usableCallID(items[$0]) + }, by: { items[$0].callId! }) + let results = Dictionary( + grouping: items.indices.filter { + isToolOutput(items[$0]) && usableCallID(items[$0]) + }, by: { items[$0].callId! }) + var paired: [Int: Int] = [:] + for (callID, callIndices) in calls { + guard callIndices.count == 1, let call = callIndices.first, + let resultIndices = results[callID], resultIndices.count == 1, + let result = resultIndices.first, result > call + else { continue } + paired[call] = result + } + let absorbed = Set(paired.values) + return items.indices.compactMap { index in + guard !absorbed.contains(index) else { return nil } + return Self(item: items[index], toolResult: paired[index].map { items[$0] }, order: index) + } + } + + private static func usableCallID(_ item: ConversationItem) -> Bool { + guard let id = item.callId else { return false } + return !id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} + +enum ChatTranscriptPresentation { + static func isGenerating(_ phase: ChatRunPhase?) -> Bool { phase == .sending || phase == .streaming } + + static func showsPending(phase: ChatRunPhase?, items: [ConversationItem]) -> Bool { + isGenerating(phase) + && !items.contains { + $0.role == "assistant" || $0.type == "reasoning" || ChatTranscriptRow.isTool($0) + } + } + + static func thoughtDuration(_ text: String) -> Int { + let words = text.split(whereSeparator: { $0.isWhitespace }).count + return max(1, Int((Double(words) / 26).rounded())) + } + + static func thinkingLabel(text: String, streaming: Bool, locale: Locale = .current) -> String { + if streaming { return MapleStrings.text("Thinking", locale: locale) } + let seconds = thoughtDuration(text) + return String( + localized: "chat.thought-duration", defaultValue: "Thought for \(seconds) seconds", + table: "Localizable", bundle: .module, locale: locale) + } +} + +enum ChatComposerPresentation { + static func isEmptyState(hasMessages: Bool, conversationID: String?) -> Bool { + !hasMessages && conversationID == nil + } + + static func expanded(newConversation: Bool, editingQueue: Bool, focused: Bool, keyboardVisible: Bool?) -> Bool { + newConversation || editingQueue || (focused && keyboardVisible != false) + } +} + +#if os(iOS) + import MapleAppCore + import MapleSDK + import SwiftUI + import UIKit + import UniformTypeIdentifiers + + struct ChatScreen: View { + @Bindable var environment: AppEnvironment + @Environment(\.horizontalSizeClass) private var sizeClass + @Environment(\.layoutDirection) private var layoutDirection + @Environment(\.mapleLaunchTransition) private var launchTransition + private var columns: Binding { + Binding( + get: { environment.preferences?.value.sidebarIsOpen == false ? .detailOnly : .all }, + set: { environment.preferences?.setSidebarIsOpen($0 != .detailOnly) }) + } + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @AccessibilityFocusState private var menuButtonFocused: Bool + @State private var compactMenuLease = CompactMenuLease() + + var body: some View { + Group { + if sizeClass == .regular { + NavigationSplitView(columnVisibility: columns) { + library(wide: true) + } detail: { + NavigationStack { + detail.safeAreaInset(edge: .bottom) { audioPlayer } + } + }.navigationSplitViewStyle(.balanced) + .onAppear { + // The regular split owns its own appearance boundary. + environment.menuPresented = false + environment.finishNavigationDismissal() + } + } else { + compactNavigation + } + } + .onChange(of: environment.menuPresented) { _, presented in + if presented { menuButtonFocused = false } + } + .onChange(of: environment.session.account?.scope) { oldScope, _ in + if let oldScope { compactMenuLease.retireAccount(scope: oldScope) } + menuButtonFocused = false + } + } + + /// Library is the navigation root. The same Chat hosting controller is + /// popped for Menu and pushed for Close, retaining its editor and scroll state. + @ViewBuilder private var compactNavigation: some View { + if let scope = environment.session.account?.scope, environment.libraryContext?.scope == scope { + CompactMenuNavigation( + scope: scope, storageOwner: environment.selectedChat?.snapshot.storageOwner, + requestedMenu: environment.menuPresented, reduceMotion: reduceMotion, lease: compactMenuLease, + library: library(wide: false) + .environment(\.layoutDirection, layoutDirection) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(MapleSurface.sidebar) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("library.drawer"), + chat: detailContent.safeAreaInset(edge: .bottom) { audioPlayer } + .compactMenuRootBar( + role: .chat, title: chatTitle, + principal: AnyView(chatHeader), trailing: AnyView(newChatControl) + ) + .environment(\.layoutDirection, layoutDirection) + .environment(\.mapleLaunchTransition, launchTransition) + .environment(\.compactMenuChatState) { [weak compactMenuLease] scope, owner in + compactMenuLease?.state(scope: scope, storageOwner: owner) ?? CompactMenuChatState() + } + .background(MapleSurface.canvas) + .modifier( + CompactMenuChatAccessibility( + lease: compactMenuLease, scope: scope, + storageOwner: environment.selectedChat?.snapshot.storageOwner, + isCurrent: { [weak environment] in + environment?.session.account?.scope == scope + && environment?.libraryContext?.scope == scope + }) + ), + isCurrent: { [weak environment] in + environment?.session.account?.scope == scope && environment?.libraryContext?.scope == scope + }, + presentationBlocked: { [weak environment] in environment?.chatNavigationBlocked ?? true }, + requestMenu: { [weak environment] presented in + guard environment?.session.account?.scope == scope else { return } + environment?.menuPresented = presented + }, + blockPresentation: { [weak environment] presentationID, blocked in + environment?.setPresentationBlocked(blocked, id: presentationID, scope: scope) + }, + chatShown: { reason in + guard environment.session.account?.scope == scope, + environment.libraryContext?.scope == scope, !environment.menuPresented + else { return } + environment.finishNavigationDismissal() + if reason == .navigation { + menuButtonFocused = environment.accountFlow == nil && environment.session.account != nil + } + } + ) + .id(scope) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea(.container) + } else { + Color.clear.accessibilityHidden(true) + } + } + + @ViewBuilder private var audioPlayer: some View { + if let listening = environment.listening { CompactReplyPlayer(session: listening).padding(.horizontal, 8) } + } + + private var detailContent: some View { + Group { + if let chat = environment.selectedChat, let details = environment.entitlements, + let preferences = environment.preferences + { + ChatDetail(environment: environment, runtime: chat, account: details, preferences: preferences) + .id(chat.snapshot.storageOwner) + } else { + Color.clear.accessibilityHidden(true) + } + } + .safeAreaInset(edge: .top, spacing: 0) { + if let account = environment.entitlements { TeamSeatMismatchNotice(account: account) } + } + #if DEBUG + .safeAreaInset(edge: .top, spacing: 0) { + if environment.configuration.profile == .local, + ProcessInfo.processInfo.arguments.contains("--maple-local-listening-fixture"), + let fixture = environment.lifecycleFixture + { + LocalLifecycleFixtureControls(driver: fixture) + } + } + #endif + .background(MapleSurface.canvas) + } + + private var detail: some View { + detailContent + .navigationTitle(chatTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar(removing: .sidebarToggle) + .toolbar { + ToolbarItem(placement: .principal) { + chatHeader + } + ToolbarItem(placement: .topBarLeading) { + Button { + showMenu() + } label: { + Label(MapleStrings.text("Menu"), systemImage: "line.3.horizontal") + .labelStyle(.iconOnly) + .frame(minWidth: 44, minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("chat.menu") + .accessibilityFocused($menuButtonFocused) + } + ToolbarItem(placement: .topBarTrailing) { + newChatControl + } + } + } + + private var chatHeader: some View { + HStack(spacing: 6) { + if !chatTitle.isEmpty { Text(chatTitle).font(.headline).lineLimit(1) } + MaplePrivacyLock() + } + } + + @ViewBuilder private var newChatControl: some View { + let scope = environment.session.account?.scope + Button { + guard let scope, scope == environment.session.account?.scope else { return } + Task { + guard scope == environment.session.account?.scope else { return } + await environment.selectNewChat() + } + } label: { + Label(MapleStrings.text("New chat"), systemImage: "square.and.pencil") + .labelStyle(.iconOnly) + .frame(minWidth: 44, minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("chat.new") + } + + @ViewBuilder private func library(wide: Bool) -> some View { + if let context = environment.libraryContext { + LibraryView( + library: context.root, makeLibrary: context.makeLibrary, + makeInstructionEditor: context.makeInstructionEditor, + onSelectChat: { id in + guard context.scope == environment.session.account?.scope else { return } + Task { + guard context.scope == environment.session.account?.scope else { return } + await environment.selectChat(id) { + guard context.scope == environment.session.account?.scope else { return } + environment.menuPresented = false + } + } + }, + onNewChat: { projectID in + guard context.scope == environment.session.account?.scope else { return } + environment.menuPresented = false + Task { + guard context.scope == environment.session.account?.scope else { return } + await environment.selectNewChat(projectID: projectID) + } + }, + selectedChatID: environment.selectedChat?.conversationID, + settings: { AnyView(SettingsScreen(environment: environment, context: context)) }, + account: environment.entitlements?.scope == context.scope ? environment.entitlements : nil, + conversationActivity: { + guard context.scope == environment.session.account?.scope else { return .idle } + return environment.activity(for: $0) + }, + projectActivity: { + guard context.scope == environment.session.account?.scope else { return .idle } + return environment.activity(forProject: $0) + }, + projectExpansion: context.projectExpansion, + menu: context.menu, + onClose: wide + ? nil + : { @MainActor @Sendable in + guard context.scope == environment.session.account?.scope else { return } + environment.menuPresented = false + }, + settingsFullScreen: wide, + settingsPresented: $environment.settingsPresented, + onSettingsDismiss: environment.finishNavigationDismissal, + drawerNavigation: !wide, + compactRootBar: !wide, + navigationAvailable: true, + navigationFocusAvailable: wide || compactMenuLease.menuVisible, + supplementary: { + AnyView(unfinishedWork) + } + ) + .id(context.scope) + .onAppear { + guard wide, context.scope == environment.session.account?.scope else { return } + environment.sidebarVisible = environment.preferences?.value.sidebarIsOpen != false + } + .onChange(of: environment.preferences?.value.sidebarIsOpen) { _, open in + guard wide, context.scope == environment.session.account?.scope else { return } + environment.sidebarVisible = open != false + } + .onDisappear { + guard wide, context.scope == environment.session.account?.scope else { return } + environment.sidebarVisible = false + } + .safeAreaInset(edge: .bottom) { if !wide { audioPlayer } } + .safeAreaInset(edge: libraryFixtureInsetEdge(wide: wide)) { + #if DEBUG + if !wide, let fixture = environment.lifecycleFixture, let proof = fixture.listeningProof { + LocalListeningFixtureControls(proof: proof, transcriber: fixture.transcriber) + } + #endif + } + } else { + Color.clear.accessibilityHidden(true) + } + } + + private func libraryFixtureInsetEdge(wide: Bool) -> VerticalEdge { + #if DEBUG + if !wide, environment.lifecycleFixture?.listeningProof != nil { + return .bottom + } + #endif + return .top + } + + @ViewBuilder private var unfinishedWork: some View { + if !environment.unfinishedChats.isEmpty { + Section(MapleStrings.text("Unfinished messages")) { + ForEach(environment.unfinishedChats, id: \.snapshot.storageOwner) { runtime in + Button(MapleStrings.text("Review unsent work")) { + guard runtime.scope == environment.session.account?.scope else { return } + environment.selectUnfinished(runtime); environment.menuPresented = false + } + } + } + } + } + + private func showMenu() { + guard let context = environment.libraryContext, + context.scope == environment.session.account?.scope + else { return } + if sizeClass == .regular { + columns.wrappedValue = columns.wrappedValue == .all ? .detailOnly : .all + } else { + environment.menuPresented = true + } + } + + private var chatTitle: String { + guard let id = environment.selectedChat?.conversationID, + case .string(let title) = environment.conversationDetails[id]?.metadata?["title"] + else { return "" } + return title + } + } + + /// Evaluate visibility inside the hosted hierarchy, after the controller + /// has acknowledged this projection's owner. A parent-built Bool can still + /// describe the previous cached chat during that installation pass. + struct CompactMenuChatAccessibility: ViewModifier { + let lease: CompactMenuLease + let scope: AccountScope + let storageOwner: UUID? + let isCurrent: @MainActor () -> Bool + + func body(content: Content) -> some View { + content.accessibilityHidden( + !isCurrent() || lease.chatProjectionAccessibilityHidden(scope: scope, storageOwner: storageOwner)) + } + } + + #if DEBUG + /// Isolated UI fixtures supply library services without restoring or + /// activating the SDK client. Their existing projection fence remains + /// required for every interaction and asynchronous completion. + struct ChatOptionsFixtureContext { + let library: LibraryModel + let projectID: @MainActor () -> String? + let makeInstructionEditor: @MainActor (InstructionDestination) -> InstructionEditor + let selectProject: @MainActor (String?) async -> Bool + } + + /// Content-free laid-out dimensions for synthetic transcript regressions. + struct TranscriptItemHorizontalGeometry: Equatable { + let minX: CGFloat + let width: CGFloat + } + #endif + + struct ChatDetail: View { + @Bindable var environment: AppEnvironment + @Environment(\.layoutDirection) private var layoutDirection + @Environment(\.horizontalSizeClass) private var sizeClass + @Environment(\.compactMenuChatState) private var compactMenuState + @Environment(\.compactMenuPresentationDismissed) private var compactMenuPresentationDismissed + @Bindable var runtime: ChatRuntime + @Bindable var account: AccountEntitlements + @Bindable var preferences: PreferencesController + #if DEBUG + var fixtureProjectionIsCurrent: (@MainActor @Sendable (AccountScope, UUID) -> Bool)? = nil + var fixtureOptions: ChatOptionsFixtureContext? = nil + var fixtureScrollMetricsChanged: (@MainActor (TranscriptViewportMetrics) -> Void)? = nil + var fixtureScrollStateChanged: (@MainActor (String) -> Void)? = nil + var fixtureItemHorizontalGeometryChanged: (@MainActor (String, TranscriptItemHorizontalGeometry) -> Void)? = + nil + var fixtureHorizontalScrollMetricsChanged: (@MainActor ([String: CGFloat]) -> Void)? = nil + #endif + @State private var queuePresented = false + @State private var modelPresented = false + @State private var contextLimitPresented = false + @State private var externalLink: URL? + @State private var browserPresentationID = UUID() + @State private var mediaNotice: MediaFeature? + @State private var composerFocused = false + @State private var softwareKeyboardVisible: Bool? + @State private var readingPosition = ScrollPosition(idType: String.self) + @State private var turnFrames: [String: TranscriptTurnGeometry] = [:] + @State private var itemFrames: [String: TranscriptItemGeometry] = [:] + @State private var preparedItems: Set = [] + @State private var tailGeometry: TranscriptTailGeometry? + @State private var readingAnchor: ReadingAnchor? + @State private var scrollMetrics = TranscriptScrollMetrics(returnRevision: 0) + @State private var correctionTask: Task? + @State private var acceptedTurnScrollTask: Task? + @State private var pendingScrollMessage: String? + @State private var pendingScrollToken = UUID() + @State private var transcriptSpace = UUID() + @State private var followPolicy = TranscriptScrollPolicy() + @State private var userScrollPhase = false + @State private var directScrollPhase = false + @State private var followTasks: [UUID: Task] = [:] + @State private var appendFollowTask: Task? + @State private var pendingAppendFollow: AppendFollowRequest? + @State private var scrollGestureRevision = 0 + @State private var historyPagination = TranscriptHistoryPagination() + @State private var historyLoadTask: Task? + @State private var historyRestorePending = false + #if DEBUG + @State private var appliedAppendFollows = 0 + private var fixtureScrollState: String { + guard fixtureScrollStateChanged != nil else { return "" } + let request = pendingAppendFollow + let tail = turns.last.flatMap { turnFrames[$0.id] } + return + "visible \(runtime.isVisible); foreground \(runtime.isForeground); user \(userScrollPhase); direct \(directScrollPhase); reader-away \(followPolicy.isUserScrolling); anchor \(readingAnchor != nil); history-owned \(historyPagination.isLoadInFlight); loading \(runtime.isLoadingOlder); pending-append \(request != nil); applied \(appliedAppendFollows); tail-current \(tail?.lastMessageID == runtime.messages.last?.id); gesture-current \(request == nil || request?.gestureRevision == scrollGestureRevision); return-current \(request == nil || request?.returnRevision == menuState.returnRevision)" + } + #endif + + private struct ReadingAnchor { + let token = UUID() + let itemID: String + let viewportY: CGFloat + var restoring = false + var corrections = 0 + } + private struct PreparedItemKey: Hashable { + let itemID: String + let visibleItemID: String + let turnID: String + } + private struct AppendFollowRequest { + let id = UUID() + let lastItemID: String + let storageOwner: UUID + let returnRevision: Int + let gestureRevision: Int + var fromPolling = false + var materialized = false + } + private struct TranscriptScrollMetrics: Equatable { + var offset: CGFloat = 0 + var topInset: CGFloat = 0 + var bottomInset: CGFloat = 0 + var contentHeight: CGFloat = 0 + var height: CGFloat = 0 + var visibleMinY: CGFloat = 0 + var visibleHeight: CGFloat = 0 + var boundsMinY: CGFloat = 0 + var boundsHeight: CGFloat = 0 + let returnRevision: Int + var maximumOffset: CGFloat? { + TranscriptScrollPolicy.maximumOffset( + contentHeight: contentHeight, viewportHeight: height) + } + var distanceFromBottom: CGFloat { + maximumOffset.map { max(0, $0 - offset - topInset) } ?? .infinity + } + } + private struct TranscriptTurnGeometry: Equatable { + let contentFrame: CGRect + let firstMessageID: String? + let lastMessageID: String? + let returnRevision: Int + } + private struct TranscriptTailGeometry: Equatable { + let itemID: String + let ready: Bool + let contentFrame: CGRect + let returnRevision: Int + } + private struct TranscriptItemGeometry: Equatable { + let itemID: String + let turnID: String + let anchorIDs: [String] + let visibleItemID: String + let ready: Bool + let contentFrame: CGRect + let firstMessageID: String? + let returnRevision: Int + } + + private func isCurrentPreparation(_ key: PreparedItemKey) -> Bool { + guard let turn = turns.first(where: { $0.id == key.turnID }) else { return false } + return ChatTranscriptRow.rows(turn.items).contains { + $0.item.id == key.itemID && ($0.toolResult?.id ?? $0.item.id) == key.visibleItemID + } + } + + private func historyTurnTarget(for anchor: ReadingAnchor) -> String? { + for turn in turns { + if ChatTranscriptRow.rows(turn.items).contains(where: { + $0.historyAnchorIDs.contains(anchor.itemID) + }) { + return turn.id + } + } + return nil + } + + private var menuState: CompactMenuChatState { + compactMenuState(runtime.scope, runtime.snapshot.storageOwner) + } + private var currentProjection: Bool { + #if DEBUG + return fixtureProjectionIsCurrent?(runtime.scope, runtime.snapshot.storageOwner) + ?? productionCurrentProjection + #else + return productionCurrentProjection + #endif + } + private var productionCurrentProjection: Bool { + environment.session.account?.scope == runtime.scope && runtime.scope == account.scope + && environment.selectedChat?.snapshot.storageOwner == runtime.snapshot.storageOwner + } + + private var transcriptMutation: TranscriptScrollPolicy.Mutation { + .init( + lastItemID: runtime.messages.last?.id, + lastItemIsAssistant: runtime.messages.last.map { $0.type != "message" || $0.role != "user" } ?? false, + hasStreamingMessage: runtime.hasUnresolvedRun + && runtime.messages.contains { + $0.type == "message" && $0.role != "user" + && ["streaming", "in_progress"].contains($0.status ?? "") + }) + } + + private func cancelFollowTasks() { + for task in followTasks.values { task.cancel() } + followTasks.removeAll() + appendFollowTask?.cancel(); appendFollowTask = nil; pendingAppendFollow = nil + } + + private func scheduleAppendFollow() { + guard let request = pendingAppendFollow else { return } + appendFollowTask?.cancel() + appendFollowTask = Task { @MainActor in + await Task.yield() + guard !Task.isCancelled, pendingAppendFollow?.id == request.id, + currentProjection, !menuState.occluded, runtime.isVisible, runtime.isForeground, + runtime.snapshot.storageOwner == request.storageOwner, + menuState.returnRevision == request.returnRevision, + scrollGestureRevision == request.gestureRevision, + request.fromPolling || !followPolicy.isUserScrolling, + !runtime.isLoadingOlder, !historyPagination.isLoadInFlight, readingAnchor == nil, !userScrollPhase, + runtime.messages.last?.id == request.lastItemID, let lastTurnID = turns.last?.id + else { return } + if request.fromPolling, turnFrames[lastTurnID] == nil, !request.materialized { + // Materialization shares every owner, gesture and history + // fence with the final scroll. The lazy tail cannot report + // its native layout until it joins the viewport. + pendingAppendFollow?.materialized = true + readingPosition.scrollTo(id: lastTurnID, anchor: .bottom) + return + } + guard + let tailGeometry, tailGeometry.itemID == request.lastItemID, tailGeometry.ready, + tailGeometry.returnRevision == request.returnRevision, + tailGeometry.contentFrame.minY.isFinite, tailGeometry.contentFrame.height > 0, + let sample = turnFrames[lastTurnID], + sample.returnRevision == request.returnRevision, + sample.contentFrame.maxY >= tailGeometry.contentFrame.maxY - 0.5, + let target = TranscriptScrollPolicy.appendedItemOffset( + expectedItemID: request.lastItemID, renderedItemID: sample.lastMessageID, + tailBottom: sample.contentFrame.maxY, + viewportHeight: scrollMetrics.height) + else { return } + // Layout, not Task.yield(), proves the appended item has joined + // the transcript. Consume this once so later text growth cannot + // keep dragging the reader to a moving bottom anchor. + pendingAppendFollow = nil + #if DEBUG + appliedAppendFollows += 1 + #endif + // The timeline scrolls vertically; nested rich-content scrollers + // must not let an implicit horizontal target move its gutters. + withAnimation(.smooth(duration: 0.25)) { readingPosition.scrollTo(x: 0, y: target) } + } + } + + private func followAppendedReplies(_ mutation: TranscriptScrollPolicy.Mutation) { + let requests = followPolicy.receive(mutation) + guard currentProjection, !menuState.occluded, runtime.isVisible, runtime.isForeground, + !runtime.isLoadingOlder, + !historyPagination.isLoadInFlight, readingAnchor == nil + else { return } + for request in requests { + if request == .appendedItem, let lastItemID = mutation.lastItemID { + pendingAppendFollow = AppendFollowRequest( + lastItemID: lastItemID, storageOwner: runtime.snapshot.storageOwner, + returnRevision: menuState.returnRevision, gestureRevision: scrollGestureRevision) + scheduleAppendFollow() + continue + } + let id = UUID(), owner = runtime.snapshot.storageOwner + let returnRevision = menuState.returnRevision, gestureRevision = scrollGestureRevision + followTasks[id] = Task { @MainActor in + defer { followTasks.removeValue(forKey: id) } + try? await Task.sleep(for: .milliseconds(100)) + guard !Task.isCancelled, currentProjection, !menuState.occluded, runtime.isVisible, + runtime.isForeground, + runtime.snapshot.storageOwner == owner, menuState.returnRevision == returnRevision, + scrollGestureRevision == gestureRevision, !followPolicy.isUserScrolling, + !runtime.isLoadingOlder, readingAnchor == nil, !userScrollPhase, + let maximum = scrollMetrics.maximumOffset + else { return } + let target: CGFloat? + switch request { + case .appendedItem: target = maximum + case .streamingStarted: + guard transcriptMutation.hasStreamingMessage else { return } + target = TranscriptScrollPolicy.streamStartOffset( + current: scrollMetrics.offset + scrollMetrics.topInset, maximum: maximum) + } + guard let target else { return } + withAnimation(.smooth(duration: 0.25)) { readingPosition.scrollTo(x: 0, y: target) } + } + } + } + + private var retainsMessageRendering: @MainActor @Sendable () -> Bool { + #if DEBUG + let fixtureProjection = fixtureProjectionIsCurrent + #endif + return { + [ + weak environment, weak runtime, weak account, scope = runtime.scope, + owner = runtime.snapshot.storageOwner + ] in + guard let environment, let runtime, let account, + account.scope == scope, runtime.scope == scope, runtime.snapshot.storageOwner == owner + else { return false } + #if DEBUG + if let fixtureProjection { + return fixtureProjection(scope, owner) + } + #endif + guard environment.session.account?.scope == scope, + environment.selectedChat?.snapshot.storageOwner == owner + else { return false } + // Leaving the viewport is temporary too. Keep completed + // formatting while this exact chat/account owner is current, + // so lazy-row re-entry cannot replace it with raw Markdown. + return true + } + } + + private func scheduleAnchorCorrection() { + guard !menuState.occluded, currentProjection, readingAnchor?.restoring == true else { return } + correctionTask?.cancel() + let token = readingAnchor?.token + correctionTask = Task { @MainActor in + // Coalesce geometry updates before issuing a stable content-space target. + await Task.yield() + guard !Task.isCancelled, !menuState.occluded, currentProjection, + var anchor = readingAnchor, anchor.token == token, + let sample = itemFrames[anchor.itemID], sample.ready, + isCurrentPreparation( + PreparedItemKey( + itemID: sample.itemID, visibleItemID: sample.visibleItemID, + turnID: sample.turnID)), + sample.firstMessageID == runtime.messages.first?.id, + sample.returnRevision == menuState.returnRevision, + scrollMetrics.returnRevision == menuState.returnRevision, + sample.contentFrame.minY.isFinite, anchor.viewportY.isFinite, + scrollMetrics.offset.isFinite, scrollMetrics.topInset.isFinite + else { return } + // Content coordinates keep this target independent of a separately + // delivered scroll-offset update from another layout pass. + let point = max(0, sample.contentFrame.minY - anchor.viewportY + scrollMetrics.topInset) + guard point.isFinite else { return } + guard abs(point - (scrollMetrics.offset + scrollMetrics.topInset)) > 0.5 else { + finishHistoryRestoration() + return + } + guard anchor.corrections < 64 else { + readingAnchor = nil; historyRestorePending = false + historyPagination.finishLoad() + return + } + anchor.corrections += 1 + readingAnchor = anchor + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { readingPosition.scrollTo(x: 0, y: point) } + } + } + + private func visibleReadingAnchor(firstMessageID: String?, returnRevision: Int) -> ReadingAnchor? { + let visible = itemFrames.values.filter { + let frame = $0.contentFrame.offsetBy(dx: 0, dy: -scrollMetrics.offset) + return $0.firstMessageID == firstMessageID && $0.returnRevision == returnRevision + && scrollMetrics.returnRevision == returnRevision + && frame.maxY > 0 && frame.minY < scrollMetrics.height + }.min { $0.contentFrame.minY < $1.contentFrame.minY } + return visible.map { + ReadingAnchor(itemID: $0.itemID, viewportY: $0.contentFrame.minY - scrollMetrics.offset) + } + } + + private func finishHistoryRestoration() { + guard historyRestorePending else { return } + historyRestorePending = false + let canLoad = + currentProjection && !menuState.occluded && runtime.isVisible && runtime.isForeground + && !runtime.isLoadingOlder && runtime.hasOlderMessages + historyPagination.finishLoad(preserveQueued: canLoad) + // The queue records a physical gesture at the previous boundary. + // The prepend's programmatic offset is not new forward intent. + if historyPagination.startQueuedLoad(canLoad: canLoad) { loadOlderMessages() } + } + + private func loadOlderMessages() { + guard currentProjection, !menuState.occluded, runtime.isVisible, runtime.isForeground, + !runtime.isLoadingOlder, runtime.hasOlderMessages + else { + return + } + cancelFollowTasks() + let previousFirst = runtime.messages.first?.id + let returnRevision = menuState.returnRevision + readingAnchor = visibleReadingAnchor(firstMessageID: previousFirst, returnRevision: returnRevision) + historyRestorePending = false + historyLoadTask?.cancel() + historyLoadTask = Task { @MainActor in + await runtime.loadOlderMessages() + guard !Task.isCancelled, currentProjection else { return } + guard runtime.messages.first?.id != previousFirst else { + readingAnchor = nil; historyRestorePending = false; historyPagination.finishLoad(); return + } + // The reader may have made a second gesture while this page was + // pending. Prefer that latest visible position at commit time. + readingAnchor = + visibleReadingAnchor(firstMessageID: previousFirst, returnRevision: returnRevision) + ?? readingAnchor + historyRestorePending = true + guard let anchor = readingAnchor else { + if turnFrames.values.contains(where: { + $0.firstMessageID == runtime.messages.first?.id + && $0.returnRevision == menuState.returnRevision + }) { + finishHistoryRestoration() + } + return + } + readingAnchor?.restoring = true + guard !menuState.occluded, runtime.isVisible, runtime.isForeground else { + historyRestorePending = false; historyPagination.finishLoad(); return + } + let token = anchor.token + // The lazy stack registers turns before their children exist. + // Find the item's current turn only to materialize that row; + // its stable item geometry restores the exact reading position. + guard let target = historyTurnTarget(for: anchor) else { + readingAnchor = nil; historyRestorePending = false; historyPagination.finishLoad(); return + } + readingPosition.scrollTo(id: target, anchor: .top) + await Task.yield() + guard !Task.isCancelled, currentProjection, !menuState.occluded, readingAnchor?.token == token else { + return + } + scheduleAnchorCorrection() + } + } + + private func resumeReadingAfterMenu(reader: ScrollViewProxy) { + guard !menuState.occluded, currentProjection else { return } + if let message = pendingScrollMessage { + acceptedTurnScrollTask?.cancel() + let token = pendingScrollToken, owner = runtime.snapshot.storageOwner + acceptedTurnScrollTask = Task { @MainActor in + await Task.yield() + guard !Task.isCancelled, currentProjection, !menuState.occluded, + runtime.snapshot.storageOwner == owner, pendingScrollToken == token, + pendingScrollMessage == message + else { return } + readingAnchor = nil; correctionTask?.cancel() + // Sending a new turn opts back into following newly appended + // reply items, just as Research's forced user-send scroll does. + followPolicy.readerScrolled(distanceFromBottom: 0) + reader.scrollTo(message, anchor: .top) + pendingScrollMessage = nil + } + } else if let anchor = readingAnchor, anchor.restoring { + if let target = historyTurnTarget(for: anchor) { + readingPosition.scrollTo(id: target, anchor: .top) + } + scheduleAnchorCorrection() + } + } + + private struct Turn: Identifiable { + let id: String + let firstItemIndex: Int + var items: [ConversationItem] + } + private var turns: [Turn] { + var result: [Turn] = [] + for (index, item) in runtime.messages.enumerated() { + if item.role == "user" || result.isEmpty { + result.append(Turn(id: item.id, firstItemIndex: index, items: [item])) + } else { + result[result.count - 1].items.append(item) + } + } + return result + } + + var body: some View { + GeometryReader { available in + GeometryReader { geometry in + ScrollViewReader { reader in + ScrollView { + LazyVStack(alignment: .leading, spacing: 24) { + if runtime.isLoadingOlder { + ProgressView() + .frame(maxWidth: .infinity, minHeight: 44) + .accessibilityLabel(MapleStrings.text("Loading earlier messages")) + .accessibilityIdentifier("chat.history.loading") + } + if runtime.messages.isEmpty, runtime.conversationID == nil { + VStack(alignment: .leading, spacing: 12) { + Text(MapleStrings.text("Research anything...")) + .font(MapleTypography.display) + .foregroundStyle(MapleSurface.displayText) + } + .padding(.bottom, 24) + .frame( + maxWidth: .infinity, + minHeight: max(0, geometry.size.height - 40), alignment: .bottomLeading + ) + .accessibilityElement(children: .combine) + } + ForEach(turns) { turn in + VStack(alignment: .leading, spacing: 24) { + ForEach(ChatTranscriptRow.rows(turn.items)) { row in + let item = row.item + let visibleItemID = row.toolResult?.id ?? item.id + let preparationKey = PreparedItemKey( + itemID: item.id, visibleItemID: visibleItemID, turnID: turn.id) + TranscriptItem( + item: item, toolResult: row.toolResult, + generation: runtime.scope.generation, + revealDelay: min(Double(turn.firstItemIndex + row.order) * 0.024, 0.12), + theme: preferences.value.theme.messageRenderTheme, + font: preferences.value.chatFont.messageRenderFont, + isReplyReady: item.status == "completed" || !runtime.hasUnresolvedRun, + isGenerating: turn.id == turns.last?.id + && ChatTranscriptPresentation.isGenerating(runtime.phase), + speechAccess: account.speechAccess, + onReadAloud: { environment.readAloud(item, runtime: runtime) }, + onSpeechUnavailable: { mediaNotice = .speech }, + onPrepared: { + guard currentProjection, runtime.scope == account.scope, + runtime.messages.contains(where: { $0.id == visibleItemID }), + isCurrentPreparation(preparationKey), + !preparedItems.contains(preparationKey) + else { return } + preparedItems.insert(preparationKey) + } + ) { url in + externalLink = url + } + .onGeometryChange(for: TranscriptItemGeometry.self) { + [ + itemID = item.id, turnID = turn.id, + returnRevision = menuState.returnRevision, + ready = preparedItems.contains(preparationKey), + firstMessageID = runtime.messages.first?.id, + anchorIDs = row.historyAnchorIDs, transcriptSpace + ] geometry in + TranscriptItemGeometry( + itemID: itemID, turnID: turnID, anchorIDs: anchorIDs, + visibleItemID: visibleItemID, ready: ready, + contentFrame: geometry.frame(in: .named(transcriptSpace)), + firstMessageID: firstMessageID, + returnRevision: returnRevision) + } action: { sample in + guard currentProjection, !menuState.occluded, + isCurrentPreparation( + PreparedItemKey( + itemID: sample.itemID, visibleItemID: sample.visibleItemID, + turnID: sample.turnID)), + sample.firstMessageID == runtime.messages.first?.id, + sample.returnRevision == menuState.returnRevision + else { return } + for id in sample.anchorIDs { itemFrames[id] = sample } + scheduleAnchorCorrection() + if sample.visibleItemID == runtime.messages.last?.id { + tailGeometry = TranscriptTailGeometry( + itemID: sample.visibleItemID, ready: sample.ready, + contentFrame: sample.contentFrame, + returnRevision: sample.returnRevision) + scheduleAppendFollow() + } + } + .onDisappear { + guard !menuState.occluded else { return } + for id in row.historyAnchorIDs + where itemFrames[id]?.itemID == item.id + && itemFrames[id]?.turnID == turn.id + { + itemFrames.removeValue(forKey: id) + } + } + #if DEBUG + .onGeometryChange(for: TranscriptItemHorizontalGeometry?.self) { + geometry in + guard fixtureItemHorizontalGeometryChanged != nil else { + return nil + } + let frame = geometry.frame(in: .global) + return TranscriptItemHorizontalGeometry( + minX: frame.minX, width: frame.width) + } action: { sample in + guard let callback = fixtureItemHorizontalGeometryChanged, + let sample, + currentProjection, !menuState.occluded, + sample.minX.isFinite, sample.width.isFinite, sample.width > 0 + else { return } + callback(item.id, sample) + } + #endif + } + if turn.id == turns.last?.id, + ChatTranscriptPresentation.showsPending( + phase: runtime.phase, items: turn.items) + { + ProgressView() + .accessibilityLabel(MapleStrings.text("Maple is responding")) + .accessibilityIdentifier("chat.pending") + } + } + .frame( + maxWidth: .infinity, + minHeight: turn.id == turns.last?.id ? max(0, geometry.size.height - 40) : 0, + alignment: .topLeading + ) + .id(turn.id) + .onGeometryChange(for: TranscriptTurnGeometry.self) { + [ + transcriptSpace, firstMessageID = runtime.messages.first?.id, + lastMessageID = runtime.messages.last?.id, + returnRevision = menuState.returnRevision + ] in + TranscriptTurnGeometry( + contentFrame: $0.frame(in: .named(transcriptSpace)), + firstMessageID: firstMessageID, lastMessageID: lastMessageID, + returnRevision: returnRevision) + } action: { sample in + guard !menuState.occluded, sample.firstMessageID == runtime.messages.first?.id, + sample.returnRevision == menuState.returnRevision, + turnFrames[turn.id] != sample + else { return } + turnFrames[turn.id] = sample + if historyRestorePending, readingAnchor == nil { finishHistoryRestoration() } + scheduleAnchorCorrection() + scheduleAppendFollow() + } + .onDisappear { + if !menuState.occluded, turnFrames[turn.id] != nil { + turnFrames.removeValue(forKey: turn.id) + } + } + } + } + .scrollTargetLayout() + // Keep the content proposal and its gutters independent of streamed block widths. + .frame(width: max(0, min(geometry.size.width, 850) - 40), alignment: .leading) + .padding(.horizontal, 20).padding(.vertical, 20) + .frame(maxWidth: .infinity) + .coordinateSpace(name: transcriptSpace) + .background { + TranscriptVerticalAxisGuard(active: currentProjection && !menuState.occluded) + .frame(width: 0, height: 0) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + } + .scrollDismissesKeyboard(.interactively) + .scrollPosition($readingPosition) + .onScrollGeometryChange(for: TranscriptScrollMetrics.self) { + [returnRevision = menuState.returnRevision] geometry in + TranscriptScrollMetrics( + offset: geometry.contentOffset.y, topInset: geometry.contentInsets.top, + bottomInset: geometry.contentInsets.bottom, contentHeight: geometry.contentSize.height, + height: geometry.containerSize.height, + visibleMinY: geometry.visibleRect.minY, visibleHeight: geometry.visibleRect.height, + boundsMinY: geometry.bounds.minY, boundsHeight: geometry.bounds.height, + returnRevision: returnRevision) + } action: { previous, metrics in + guard !menuState.occluded, metrics.returnRevision == menuState.returnRevision else { + return + } + scrollMetrics = metrics + #if DEBUG + if metrics.distanceFromBottom.isFinite { + fixtureScrollMetricsChanged?( + .init( + offset: metrics.offset + metrics.topInset, + distanceFromBottom: metrics.distanceFromBottom, + viewportHeight: metrics.height, rawOffset: metrics.offset, + topInset: metrics.topInset, bottomInset: metrics.bottomInset, + contentHeight: metrics.contentHeight, visibleMinY: metrics.visibleMinY, + visibleHeight: metrics.visibleHeight, + visibleMaxY: metrics.visibleMinY + metrics.visibleHeight, + boundsMinY: metrics.boundsMinY, boundsHeight: metrics.boundsHeight)) + } + #endif + if userScrollPhase, readingAnchor?.restoring != true { + followPolicy.readerScrolled(distanceFromBottom: metrics.distanceFromBottom) + if runtime.isLoadingOlder, historyPagination.isLoadInFlight { + readingAnchor = visibleReadingAnchor( + firstMessageID: runtime.messages.first?.id, + returnRevision: menuState.returnRevision) + } + if historyPagination.shouldLoad( + previousOffset: previous.offset + previous.topInset, + offset: metrics.offset + metrics.topInset, + canLoad: currentProjection && runtime.hasOlderMessages, + requestInFlight: runtime.isLoadingOlder || readingAnchor?.restoring == true, + directUserInput: directScrollPhase) + { + loadOlderMessages() + } + } + scheduleAnchorCorrection() + scheduleAppendFollow() + } + #if DEBUG + .onScrollGeometryChange(for: [String: CGFloat]?.self) { geometry in + guard fixtureHorizontalScrollMetricsChanged != nil else { return nil } + return [ + "offsetX": geometry.contentOffset.x, + "contentWidth": geometry.contentSize.width, + "viewportWidth": geometry.containerSize.width, + "boundsMinX": geometry.bounds.minX, + "boundsWidth": geometry.bounds.width, + "visibleMinX": geometry.visibleRect.minX, + "visibleWidth": geometry.visibleRect.width, + "leadingInset": geometry.contentInsets.leading, + "trailingInset": geometry.contentInsets.trailing, + ] + } action: { _, metrics in + guard let callback = fixtureHorizontalScrollMetricsChanged, let metrics, + currentProjection, !menuState.occluded, metrics.values.allSatisfy(\.isFinite) + else { return } + callback(metrics) + } + #endif + .onScrollPhaseChange { previous, phase in + if !menuState.occluded, phase == .tracking || phase == .interacting, + previous != .tracking && previous != .interacting + { + readingAnchor = nil; correctionTask?.cancel() + scrollGestureRevision += 1; cancelFollowTasks() + historyPagination.gestureChanged(active: false) + // A new reader gesture ends the previous + // request's correction once its page is laid + // out; it must not strand the owned gate. + if historyRestorePending, !runtime.isLoadingOlder, + turnFrames.values.contains(where: { + $0.firstMessageID == runtime.messages.first?.id + && $0.returnRevision == menuState.returnRevision + }) + { + finishHistoryRestoration() + } + } + if !menuState.occluded { + let wasUserScrolling = userScrollPhase + userScrollPhase = phase == .tracking || phase == .interacting || phase == .decelerating + directScrollPhase = phase == .tracking || phase == .interacting + historyPagination.gestureChanged(active: userScrollPhase) + if userScrollPhase || wasUserScrolling { + followPolicy.readerScrolled(distanceFromBottom: scrollMetrics.distanceFromBottom) + } + scheduleAppendFollow() + } + } + .accessibilityIdentifier("chat.transcript") + .onChange(of: runtime.snapshot.pendingRun?.id) { _, id in + guard id != nil, let message = runtime.snapshot.pendingRun?.messageID else { return } + // One anchor change per accepted user turn, never per token. + // A hidden retained Chat defers it until the actual return. + pendingScrollMessage = message; pendingScrollToken = UUID() + resumeReadingAfterMenu(reader: reader) + } + .onChange(of: menuState.returnRevision) { _, _ in + resumeReadingAfterMenu(reader: reader) + } + .onChange(of: runtime.isForeground) { _, foreground in + if foreground { + resumeReadingAfterMenu(reader: reader) + } else { + historyPagination.resetIntent() + } + } + .onChange(of: runtime.isVisible) { _, visible in + if !visible { historyPagination.resetIntent() } + } + .onChange(of: transcriptMutation) { _, mutation in + followAppendedReplies(mutation) + } + .onChange(of: runtime.polledMessagesRevision) { _, _ in + guard currentProjection, !menuState.occluded, runtime.isVisible, runtime.isForeground, + let last = runtime.messages.last?.id + else { + return + } + cancelFollowTasks(); readingAnchor = nil; correctionTask?.cancel() + pendingAppendFollow = AppendFollowRequest( + lastItemID: last, storageOwner: runtime.snapshot.storageOwner, + returnRevision: menuState.returnRevision, gestureRevision: scrollGestureRevision, + fromPolling: true) + scheduleAppendFollow() + } + .onAppear { resumeReadingAfterMenu(reader: reader) } + } + } + .safeAreaInset(edge: .bottom, spacing: 0) { composer(maximumHeight: available.size.height) } + } + .onDisappear { + correctionTask?.cancel(); correctionTask = nil + acceptedTurnScrollTask?.cancel(); acceptedTurnScrollTask = nil + historyPagination.resetIntent() + directScrollPhase = false + if !menuState.occluded { + historyLoadTask?.cancel(); historyLoadTask = nil + historyRestorePending = false; historyPagination.finishLoad() + } + cancelFollowTasks() + if !menuState.occluded { readingAnchor = nil; pendingScrollMessage = nil } + } + .environment(\.retainedMessageRendering, retainsMessageRendering) + .tracksChatPresentation(queuePresented || modelPresented || mediaNotice != nil || contextLimitPresented) + .environment(\.chatPresentationChanged) { id, presented in + environment.setPresentationBlocked(presented, id: id, scope: runtime.scope) + } + .onChange(of: runtime.lastIssueID, initial: true) { _, _ in + // Rejected drafts have already been restored by the runtime. + presentContextLimitIfNeeded() + } + .alert(MapleStrings.text("Message Too Large"), isPresented: $contextLimitPresented) { + Button(MapleStrings.text("Got it"), role: .cancel) {} + } message: { + Text(ChatRecoveryPresentation.contextLimitMessage) + } + .onChange(of: composerFocused) { _, focused in + #if DEBUG + ComposerFocusTrace.record("shipping.focusChanged=\(focused)") + #endif + if !focused { softwareKeyboardVisible = nil } + } + .onChange(of: menuState.occluded) { _, occluded in + if occluded { + composerFocused = false; correctionTask?.cancel(); acceptedTurnScrollTask?.cancel() + cancelFollowTasks(); userScrollPhase = false + directScrollPhase = false + historyPagination.resetIntent() + if historyRestorePending { + historyRestorePending = false; historyPagination.finishLoad() + } + } else { + presentContextLimitIfNeeded() + } + } + .onChange(of: menuState.returnRevision) { _, _ in + if menuState.focusNewProjection, runtime.conversationID == nil, currentProjection { + composerFocused = true + } + } + #if DEBUG + .onChange(of: fixtureScrollState, initial: true) { _, state in + fixtureScrollStateChanged?(state) + } + #endif + .onAppear { + environment.updatePresentationVisibility() + presentContextLimitIfNeeded() + if !menuState.occluded, runtime.conversationID == nil { composerFocused = true } + } + .externalLinks($externalLink) { presented in + environment.setPresentationBlocked(presented, id: browserPresentationID, scope: runtime.scope) + } + .sheet(item: $mediaNotice, onDismiss: compactMenuPresentationDismissed) { feature in + MediaFeatureNotice(feature: feature, account: account) + } + .sheet(isPresented: $modelPresented, onDismiss: compactMenuPresentationDismissed) { + let scope = runtime.scope, owner = runtime.snapshot.storageOwner + if let context = environment.libraryContext, context.scope == scope { + ChatOptionsSheet( + entitlements: account, preferences: preferences, library: context.root, + regularWidth: sizeClass == .regular, + selectedProjectID: environment.projectID(for: runtime), + makeInstructionEditor: context.makeInstructionEditor, + isCurrent: { + modelPresented && environment.session.account?.scope == scope && runtime.scope == scope + && runtime.snapshot.storageOwner == owner && currentProjection + }, + onSelectProject: { id in await environment.selectProject(runtime: runtime, id: id) }, + onSelectModel: { id in + guard modelPresented, environment.session.account?.scope == scope, + runtime.scope == scope, runtime.snapshot.storageOwner == owner, currentProjection + else { return } + preferences.setSelectedModelID(id); modelPresented = false + }) + } + #if DEBUG + if environment.libraryContext == nil, let fixtureOptions, fixtureOptions.library.scope == scope, + fixtureProjectionIsCurrent != nil + { + ChatOptionsSheet( + entitlements: account, preferences: preferences, library: fixtureOptions.library, + regularWidth: sizeClass == .regular, + selectedProjectID: fixtureOptions.projectID(), + makeInstructionEditor: fixtureOptions.makeInstructionEditor, + isCurrent: { + modelPresented && runtime.scope == scope && account.scope == scope + && runtime.snapshot.storageOwner == owner && currentProjection + }, + onSelectProject: fixtureOptions.selectProject, + onSelectModel: { id in + guard modelPresented, runtime.scope == scope, account.scope == scope, + runtime.snapshot.storageOwner == owner, currentProjection + else { return } + preferences.setSelectedModelID(id); modelPresented = false + }) + } + #endif + } + .onDisappear { + // Outer navigation hides this exact retained projection temporarily. + // Selection replacement and account teardown still retire it normally. + guard !menuState.occluded else { return } + environment.setPresentationBlocked(false, id: browserPresentationID, scope: runtime.scope) + environment.updatePresentationVisibility(); Task { await runtime.saveDraft() } + } + .sheet(isPresented: $queuePresented, onDismiss: compactMenuPresentationDismissed) { + QueueReview(runtime: runtime) { composerFocused = true } + .presentationDetents([.medium, .large]).presentationDragIndicator(.visible) + } + } + + private func presentContextLimitIfNeeded() { + guard currentProjection, !menuState.occluded, + let issueID = runtime.pendingIssuePresentationID(for: .contextLimit), + runtime.acknowledgeIssuePresentation(id: issueID) + else { return } + contextLimitPresented = true + } + + private func composer(maximumHeight: CGFloat) -> some View { + let owner = runtime.snapshot.draft.id + let projectionOwner = runtime.snapshot.storageOwner, scope = runtime.scope + let padding: CGFloat = 8, bottomPadding: CGFloat = 4 + return ComposerVerticalLayout( + maximumHeight: max(0, maximumHeight - padding * 2 - bottomPadding), + layoutDirection: layoutDirection, spacing: 6 + ) { + if runtime.isEditingQueue { + HStack { + Label(MapleStrings.text("Editing queued message"), systemImage: "pencil").font(.footnote) + Spacer() + Button(MapleStrings.text("Cancel")) { runtime.cancelQueueEdit() } + Button(MapleStrings.text("Save changes")) { _ = runtime.saveQueueEdit() } + .disabled(runtime.busyDraftIDs.contains(runtime.snapshot.draft.id)) + .accessibilityIdentifier("queue.save") + } + } + if !runtime.queuedTurns.isEmpty { + Button { + queuePresented = true + } label: { + Label( + MapleStrings.queuedMessageCount(runtime.queuedTurns.count), + systemImage: "text.line.first.and.arrowtriangle.forward" + ) + .frame(minWidth: 44, minHeight: 44, alignment: .leading) + .contentShape(Rectangle()) + }.font(.footnote).accessibilityIdentifier("queue.open") + } + if runtime.hasUnresolvedRun, runtime.canSend, !runtime.isEditingQueue { + Button(MapleStrings.text("Queue message"), systemImage: "text.line.first.and.arrowtriangle.forward") + { + send() + } + .font(.footnote).frame(minHeight: 44) + .accessibilityIdentifier("queue.enqueue") + } + if !runtime.isOnline { + Label(MapleStrings.text("Offline — your draft stays here"), systemImage: "wifi.slash") + .font(.footnote).foregroundStyle(.secondary) + } + if !runtime.deletionFenceIDs.isEmpty, runtime.lastIssue != .conversationDeleting { + Text(ChatRecoveryPresentation.deletionPauseMessage) + .font(.footnote).foregroundStyle(.secondary) + .accessibilityIdentifier("chat.deletion.paused") + } + if account.chatAccess != .allowed { + AccountAccessNotice(account: account) + } + ChatRecoveryControls(runtime: runtime, productName: account.billing.value?.productName) + if let importer = environment.imageImporters[ObjectIdentifier(runtime)] { + DraftImageStrip(importer: importer) + if let issue = importer.issue { ImageImportIssueView(issue: issue) } + } + if let dictation = environment.dictation { DictationControls(coordinator: dictation, runtime: runtime) } + ComposerInputLayout(expanded: composerExpanded, layoutDirection: layoutDirection) { + attachmentButton.layoutValue(key: ComposerInputRoleKey.self, value: .attachment) + Group { + if runtime.isLoading { + Color.clear.frame(height: 44).accessibilityIdentifier("chat.loading") + } else { + composerEditor(owner: owner) + } + }.layoutValue(key: ComposerInputRoleKey.self, value: .editor) + modelButton.layoutValue(key: ComposerInputRoleKey.self, value: .model) + microphoneButton.layoutValue(key: ComposerInputRoleKey.self, value: .microphone) + primaryAction.layoutValue(key: ComposerInputRoleKey.self, value: .action) + } + .padding(8) + .glassEffect(.regular, in: RoundedRectangle(cornerRadius: composerExpanded ? 24 : 32)) + .background { + ComposerKeyboardVisibility(eligible: composerFocused && currentProjection && !menuState.occluded) { + visible in + guard runtime.scope == scope, runtime.snapshot.storageOwner == projectionOwner, + currentProjection, !menuState.occluded + else { return } + softwareKeyboardVisible = visible + } + .allowsHitTesting(false).accessibilityHidden(true) + } + .layoutValue(key: ComposerEditorLayoutKey.self, value: true) + if ChatComposerPresentation.isEmptyState( + hasMessages: !runtime.messages.isEmpty, conversationID: runtime.conversationID) + { + HStack(spacing: 4) { + MaplePrivacyLock().environment(\.mapleLaunchTransition, nil).accessibilityHidden(true) + Text(MapleStrings.text("Encrypted and private at every step")) + } + .font(.footnote).foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("composer.privacy") + } else { + Text(MapleStrings.text("AI can make mistakes. Check important info.")) + .font(.footnote).foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("composer.disclaimer") + } + } + // Keep the trailing controls clear of the keyboard's prediction strip. + .padding(padding).padding(.bottom, bottomPadding).frame(maxWidth: 874).frame(maxWidth: .infinity) + } + + private var composerExpanded: Bool { + ChatComposerPresentation.expanded( + newConversation: runtime.conversationID == nil, editingQueue: runtime.isEditingQueue, + focused: composerFocused, keyboardVisible: softwareKeyboardVisible) + } + + /// Capture a fresh draft owner for both SwiftUI updates and synchronous Send handoff. + private func composerEditor(owner: UUID) -> ComposerEditor { + ComposerEditor( + text: Binding( + get: { runtime.ownedDraft(id: owner)?.text ?? "" }, + set: { value in + guard var draft = runtime.ownedDraft(id: owner) else { return } + draft.text = value; _ = runtime.updateOwnedDraft(draft) + }), focused: $composerFocused, ownerID: owner, accountGeneration: runtime.scope.generation, + isCurrentOwner: { + runtime.scope == account.scope && runtime.snapshot.draft.id == owner + }, + isCurrentPresentation: { currentProjection && !menuState.occluded }, + placeholder: MapleStrings.text("Message Maple..."), + isCompact: !composerExpanded, + externalSelection: environment.dictation?.editorState(runtime: runtime, draftID: owner), + onChange: { state, edits in + environment.dictation?.updateEditor( + runtime: runtime, draftID: owner, state: state, edits: edits) + }, + onEditingInteraction: { + guard runtime.scope == account.scope, + runtime.snapshot.draft.id == owner, currentProjection, !menuState.occluded + else { + #if DEBUG + ComposerFocusTrace.record("shipping.interactionRejected") + #endif + return false + } + softwareKeyboardVisible = nil + #if DEBUG + ComposerFocusTrace.record("shipping.interactionAdmitted; focused=\(composerFocused)") + #endif + return true + }, + refreshAfterSubmit: { + guard environment.session.account?.scope == runtime.scope else { return nil } + return self.composerEditor(owner: runtime.snapshot.draft.id) + }, + onPasteImages: { providers in + guard runtime.scope == account.scope, runtime.snapshot.draft.id == owner, + currentProjection, !menuState.occluded + else { return true } + guard !runtime.isEditingQueue else { return false } + guard let importer = environment.imageImporters[ObjectIdentifier(runtime)] else { return true } + guard account.imageAccess == .allowed else { mediaNotice = .images; return true } + ClipboardImageSource.importImages(providers, using: importer, draftID: owner) + return true + }, + onSubmit: { + guard runtime.snapshot.draft.id == owner else { return } + send() + } + ) + } + + @ViewBuilder private var attachmentButton: some View { + if let importer = environment.imageImporters[ObjectIdentifier(runtime)] { + ImageAttachmentMenu( + importer: importer, access: account.imageAccess, + onUnavailable: { mediaNotice = .images }) + } + } + private var modelShortName: String { + account.selectedModel.map { $0.shortName.isEmpty ? $0.label : $0.shortName } ?? account.selectedModelID + } + + private var modelButton: some View { + Button { + environment.dictation?.stopCaptureForNavigation() + composerFocused = false + modelPresented = true + } label: { + HStack(spacing: 4) { + Image(systemName: "slider.horizontal.3") + Text(verbatim: modelShortName) + .font(.footnote.weight(.medium)) + if account.resolvedThinkingLevel.effective != .default { + Text(verbatim: thinkingLevelLabel) + .font(.footnote).foregroundStyle(.secondary) + } + } + .frame(minWidth: 44, minHeight: 44) + .contentShape(Rectangle()) + } + .accessibilityLabel(Text("Chat options", tableName: "Settings", bundle: .module)) + .accessibilityValue(Text(verbatim: modelShortName + thinkingLevelSuffix)) + .accessibilityIdentifier("chat.model") + } + private var thinkingLevelSuffix: String { + let level = account.resolvedThinkingLevel.effective + return level == .default ? "" : ", " + thinkingLevelLabel + } + private var thinkingLevelLabel: String { + switch account.resolvedThinkingLevel.effective { + case .default: String(localized: "Default", table: "Settings", bundle: .module) + case .none: String(localized: "Off", table: "Settings", bundle: .module) + case .minimal: String(localized: "Minimal", table: "Settings", bundle: .module) + case .low: String(localized: "Low", table: "Settings", bundle: .module) + case .medium: String(localized: "Medium", table: "Settings", bundle: .module) + case .high: String(localized: "High", table: "Settings", bundle: .module) + case .xhigh: String(localized: "Extra high", table: "Settings", bundle: .module) + case .max: String(localized: "Max", table: "Settings", bundle: .module) + } + } + @ViewBuilder private var microphoneButton: some View { + if let dictation = environment.dictation { + if account.transcriptionAccess == .allowed || dictation.status(for: runtime)?.phase == .recording { + DictationMicrophoneButton(coordinator: dictation, runtime: runtime, isEnabled: true) + .labelStyle(.iconOnly).frame(minWidth: 44, minHeight: 44) + } else { + Button { + mediaNotice = .dictation + } label: { + Label(MapleStrings.text("Dictate"), systemImage: "mic") + .frame(minWidth: 44, minHeight: 44) + .contentShape(Rectangle()) + } + .labelStyle(.iconOnly) + .accessibilityValue(MapleStrings.text("Unavailable")) + .accessibilityHint(MapleStrings.text("Learn why this feature is unavailable.")) + .accessibilityIdentifier("dictation.start") + } + } + } + @ViewBuilder private var primaryAction: some View { + if !runtime.isEditingQueue || runtime.hasUnresolvedRun { + Button { + if runtime.hasUnresolvedRun { runtime.stop() } else { send() } + } label: { + Label( + runtime.hasUnresolvedRun ? MapleStrings.text("Stop") : MapleStrings.text("Send"), + systemImage: runtime.hasUnresolvedRun ? "stop.fill" : "arrow.up" + ) + .frame(minWidth: 44, minHeight: 44) + .contentShape(Rectangle()) + } + .labelStyle(.iconOnly).buttonStyle(MaplePrimaryButtonStyle(compact: true)) + .disabled(!runtime.hasUnresolvedRun && (!runtime.canSend || account.chatAccess != .allowed)) + .accessibilityIdentifier(runtime.hasUnresolvedRun ? "chat.stop" : "chat.send") + } + } + + private func send() { + guard !runtime.snapshot.draft.isEmpty, account.chatAccess == .allowed else { return } + if environment.send( + runtime, + options: ChatSendOptions( + model: account.selectedModelID, webSearch: account.effectiveWebSearch, + projectID: environment.selectedProjectID, reasoningEffort: account.resolvedThinkingLevel.effort)) + { + composerFocused = false + } + } + + } + + private struct TranscriptItem: View { + let item: ConversationItem + let toolResult: ConversationItem? + let generation: UUID + let revealDelay: Double + let theme: MessageRenderTheme + let font: MessageRenderFont + let isReplyReady: Bool + let isGenerating: Bool + let speechAccess: AccountAccess + let onReadAloud: () -> Void + let onSpeechUnavailable: () -> Void + let onPrepared: @MainActor @Sendable () -> Void + let onOpenLink: (URL) -> Void + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var appeared = false + @State private var copied = false + @State private var copyRevision = UUID() + @State private var thinkingPulse = false + @State private var legacyPrepared: ChatLegacyContent.Prepared? + @State private var readySource: ChatLegacyContent.Source? + @State private var readyFragments: Set = [] + private var currentLegacySource: ChatLegacyContent.Source { + ChatLegacyContent.Source( + text: text, user: isUserMessage, + assistant: item.role == "assistant" && item.type == "message" && text.contains(""), + streaming: isStreamingText, generation: generation) + } + private var legacyPresentation: ChatLegacyContent.Presentation { + ChatLegacyContent.presentation(source: currentLegacySource, prepared: legacyPrepared) + } + private var text: String { (item.content ?? []).compactMap(\.text).joined(separator: "\n\n") } + private var readableContent: Bool { + !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || (item.content ?? []).contains(where: { $0.imageUrl != nil }) + } + private var revealsContent: Bool { + ["user", "assistant"].contains(item.role ?? "") + && !ChatTranscriptRow.isTool(item) + } + + private var isUserMessage: Bool { item.role == "user" && item.type == "message" } + private var isThinking: Bool { + item.type == "reasoning" && isGenerating + && (["in_progress", "streaming"].contains(item.status ?? "") || item.status == nil) + } + private var isStreamingText: Bool { + isGenerating && ["in_progress", "streaming"].contains(item.status ?? "in_progress") + } + private var thinkingActive: Bool { + if isThinking { return true } + if case .assistant(let parts) = legacyPresentation { return parts.contains(where: \.isThinking) } + return legacyPresentation == .pendingThinking && isStreamingText + } + private var requiredFragments: Set { + if currentLegacySource.requiresPreparation, legacyPrepared?.source != currentLegacySource { return [] } + switch legacyPresentation { + case .document(let document): + var fragments: Set = [-1] + if !document.before.isEmpty { fragments.insert(-2) } + if !document.after.isEmpty { fragments.insert(-3) } + return fragments + case .assistant(let parts): + return parts.isEmpty ? [0] : Set(parts.map(\.id)) + case .pendingDocument, .pendingThinking: return [] + case .ordinary: return [0] + } + } + private func fragmentPrepared(_ id: Int, source: ChatLegacyContent.Source? = nil) { + let source = source ?? currentLegacySource + guard source == currentLegacySource else { return } + if readySource != source { readySource = source; readyFragments = [] } + if !readyFragments.contains(id) { readyFragments.insert(id) } + publishPreparationIfReady() + } + private func publishPreparationIfReady() { + let required = requiredFragments + if readySource == currentLegacySource, !required.isEmpty, required.isSubset(of: readyFragments) { + onPrepared() + } + } + + var body: some View { + HStack(alignment: .top, spacing: 0) { + if isUserMessage { Spacer(minLength: 36) } + VStack(alignment: isUserMessage ? .trailing : .leading, spacing: 8) { + if isUserMessage { + messageContent + .padding(12) + .background(MapleSurface.card, in: RoundedRectangle(cornerRadius: 18)) + .overlay { + RoundedRectangle(cornerRadius: 18) + .strokeBorder(MapleSurface.border.opacity(0.3), lineWidth: 0.5) + } + .accessibilityElement(children: .contain) + .accessibilityLabel(Text("You", bundle: .module)) + .accessibilityIdentifier("message.bubble." + item.id) + } else { + messageContent + } + if item.status == "incomplete" { + Text(MapleStrings.text("Chat Canceled")) + .font(.caption).foregroundStyle(.secondary) + .accessibilityIdentifier("message.cancelled." + item.id) + } + if item.type != "reasoning", !ChatTranscriptRow.isTool(item), !text.isEmpty { + messageActions + } + } + .frame(maxWidth: isUserMessage ? 650 : .infinity, alignment: isUserMessage ? .trailing : .leading) + } + .frame(maxWidth: .infinity, alignment: isUserMessage ? .trailing : .leading) + .opacity(readableContent && revealsContent && !appeared && !reduceMotion ? 0 : 1) + .onAppear { + revealContent() + if text.isEmpty, !ChatTranscriptRow.isTool(item) { fragmentPrepared(0) } + } + .onChange(of: readableContent) { _, _ in revealContent() } + .task(id: copyRevision) { + guard copied else { return } + try? await Task.sleep(nanoseconds: 2_000_000_000) + if !Task.isCancelled { copied = false } + } + .onChange(of: thinkingActive, initial: true) { _, thinking in + thinkingPulse = thinking + } + .task(id: currentLegacySource) { + let source = currentLegacySource + guard source.requiresPreparation else { legacyPrepared = nil; return } + let work = Task.detached(priority: .userInitiated) { + ChatLegacyContent.prepare(source) + } + let prepared = await work.value + guard !Task.isCancelled, currentLegacySource == source else { return } + legacyPrepared = prepared + if prepared.document != nil { fragmentPrepared(-1, source: source) } + if source.assistant, prepared.parts.isEmpty { fragmentPrepared(0, source: source) } + } + .onChange(of: requiredFragments) { _, _ in publishPreparationIfReady() } + .accessibilityElement(children: .contain) + .accessibilityIdentifier((item.role == "assistant" ? "assistant.message." : "message.") + item.id) + } + + private var messageContent: some View { + VStack(alignment: .leading, spacing: 8) { + if (item.content ?? []).contains(where: { $0.imageUrl != nil }) { + MessageImages(content: item.content ?? []) + } + if item.type == "reasoning" { + thinkingContent( + text, streaming: isThinking, identifier: "message.thinking." + item.id, fragmentID: 0) + } else if ChatTranscriptRow.isTool(item) { + ToolActivityView( + item: item, result: toolResult, theme: theme, font: font, generation: generation, + onOpenLink: onOpenLink, onPrepared: { fragmentPrepared(0) }) + } else if !text.isEmpty { + switch legacyPresentation { + case .document(let document): + if !document.before.isEmpty { ordinaryText(document.before, fragmentID: -2) } + LegacyDocumentChip(document: document, itemID: item.id, generation: generation) + if !document.after.isEmpty { ordinaryText(document.after, fragmentID: -3) } + case .assistant(let parts): + ForEach(parts) { part in + if part.kind == .thinking { + thinkingContent( + part.text, streaming: part.isThinking, + identifier: "message.thinking." + item.id + "." + String(part.id), + fragmentID: part.id) + } else { + ordinaryText(part.text, fragmentID: part.id) + } + } + case .pendingDocument: + ProgressView() + .frame(minHeight: 44) + .accessibilityLabel(libraryText("Loading...")) + .accessibilityIdentifier("message.document.loading." + item.id) + case .pendingThinking: + HStack(spacing: 8) { + Text(MapleStrings.text("Thinking")).foregroundStyle(.secondary) + ProgressView().controlSize(.mini) + } + .frame(minHeight: 44) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("message.thinking.loading." + item.id) + case .ordinary: + ordinaryText(text) + } + } else if item.role == "assistant", isGenerating, + !["completed", "incomplete", "failed"].contains(item.status ?? "") + { + ProgressView() + .accessibilityLabel(MapleStrings.text("Maple is responding")) + .accessibilityIdentifier("message.pending." + item.id) + } + } + } + + private func ordinaryText(_ text: String, fragmentID: Int = 0) -> some View { + let source = currentLegacySource + return MessageView( + text: text, theme: theme, font: font, accountGeneration: generation, onOpenLink: onOpenLink + ) + .onPrepared { fragmentPrepared(fragmentID, source: source) } + } + + private func thinkingContent(_ text: String, streaming: Bool, identifier: String, fragmentID: Int) -> some View + { + let source = currentLegacySource + return DisclosureGroup { + ordinaryText(text, fragmentID: fragmentID) + } label: { + Text(ChatTranscriptPresentation.thinkingLabel(text: text, streaming: streaming)) + .foregroundStyle(.secondary) + .opacity(streaming && thinkingPulse && !reduceMotion ? 0.5 : 1) + .animation( + streaming && !reduceMotion ? .easeInOut(duration: 1).repeatForever(autoreverses: true) : nil, + value: thinkingPulse) + } + .frame(minHeight: 44) + .accessibilityIdentifier(identifier) + .onChange(of: source, initial: true) { _, value in fragmentPrepared(fragmentID, source: value) } + } + + private var messageActions: some View { + HStack(spacing: 8) { + Button( + MapleStrings.text(copied ? "Copied" : "Copy message"), + systemImage: copied ? "checkmark" : "doc.on.doc" + ) { + UIPasteboard.general.setItems( + [[UTType.utf8PlainText.identifier: text]], + options: [.localOnly: true, .expirationDate: Date().addingTimeInterval(120)]) + copied = true; copyRevision = UUID() + }.labelStyle(.iconOnly).buttonStyle(.borderless).frame(minWidth: 44, minHeight: 44) + .accessibilityIdentifier("message.copy." + item.id) + if item.role == "assistant" { + Button( + MapleStrings.text("Read reply aloud"), systemImage: "speaker.wave.2", + action: speechAccess == .allowed ? onReadAloud : onSpeechUnavailable + ) + .labelStyle(.iconOnly).buttonStyle(.borderless).frame(minWidth: 44, minHeight: 44) + .disabled(!isReplyReady) + .accessibilityValue(speechAccess == .allowed ? "" : MapleStrings.text("Unavailable")) + .accessibilityHint( + !isReplyReady + ? MapleStrings.text("Available after this reply finishes.") + : speechAccess == .allowed + ? "" + : MapleStrings.text("Learn why this feature is unavailable.") + ) + .accessibilityIdentifier("message.speak." + item.id) + } + } + } + private func revealContent() { + guard !appeared, readableContent else { return } + if revealsContent, !reduceMotion { + withAnimation(.easeOut(duration: 0.18).delay(revealDelay)) { appeared = true } + } else { + appeared = true + } + } + } + + private struct LegacyDocumentChip: View { + let document: ChatLegacyContent.Document + let itemID: String + let generation: UUID + @State private var presented = false + @Environment(\.compactMenuPresentationDismissed) private var compactMenuPresentationDismissed + + var body: some View { + Button { + presented = true + } label: { + VStack(spacing: 4) { + Image(systemName: "doc.text").font(.title2) + Text(verbatim: document.filename).font(.caption).lineLimit(1) + } + .frame(width: 64, height: 64).padding(8) + .background(MapleSurface.card, in: RoundedRectangle(cornerRadius: 12)) + .overlay { RoundedRectangle(cornerRadius: 12).strokeBorder(MapleSurface.border) } + } + .buttonStyle(.plain) + .accessibilityLabel( + Text( + String( + localized: "chat.document-preview", defaultValue: "Preview document: \(document.filename)", + table: "Localizable", bundle: .module)) + ) + .accessibilityIdentifier("message.document." + itemID) + .tracksChatPresentation(presented) + .onChange(of: generation) { _, _ in presented = false } + .sheet(isPresented: $presented, onDismiss: compactMenuPresentationDismissed) { + NavigationStack { + ScrollView { + Text( + verbatim: document.text.isEmpty ? MapleStrings.text("No content available") : document.text + ) + .font(.body).textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading).padding() + .accessibilityIdentifier("message.document.content." + itemID) + } + .navigationTitle(document.filename).navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(MapleStrings.text("Done")) { presented = false } + .frame(minWidth: 44, minHeight: 44) + } + } + } + } + } + } + + private struct ToolActivityView: View { + let item: ConversationItem + let result: ConversationItem? + let theme: MessageRenderTheme + let font: MessageRenderFont + let generation: UUID + let onOpenLink: (URL) -> Void + let onPrepared: @MainActor @Sendable () -> Void + @State private var expanded = false + @State private var details = ChatToolPresentation.Details(input: "", output: "") + @State private var outputExpanded = false + @State private var preparedTitle: String? + @State private var outputPreview = "" + @State private var detailsRevision = UUID() + private var status: ChatToolPresentation.Status { ChatToolPresentation.status(item: item, result: result) } + private var failed: Bool { status == .failed } + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Group { + if details.hasDetails { + Button { + expanded.toggle() + } label: { + summary + } + .buttonStyle(.plain) + .accessibilityValue( + MapleStrings.text(expanded ? "Expanded" : "Collapsed") + ", " + statusLabel) + } else { + summary.accessibilityValue(statusLabel) + } + } + .accessibilityIdentifier("tool.activity." + item.id) + if expanded, details.hasDetails { + VStack(alignment: .leading, spacing: 8) { + if !details.input.isEmpty { + Text(MapleStrings.text("Input")).font(.caption.weight(.medium)).textCase(.uppercase) + ScrollView { + Text(verbatim: details.input).font(.caption.monospaced()).textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading).padding(8) + }.frame(maxHeight: 224).background(MapleSurface.card, in: RoundedRectangle(cornerRadius: 8)) + } + if !details.output.isEmpty { + Text(MapleStrings.text("Output")).font(.caption.weight(.medium)).textCase(.uppercase) + MessageView( + text: outputExpanded ? details.output : outputPreview, + theme: theme, font: font, accountGeneration: generation, + onOpenLink: onOpenLink + ) + .onPrepared(onPrepared) + if details.output.utf16.count > 150 { + Button(MapleStrings.text(outputExpanded ? "Show less" : "Show more")) { + outputExpanded.toggle() + } + .font(.caption.weight(.medium)).frame(minHeight: 44) + .accessibilityValue(MapleStrings.text(outputExpanded ? "Expanded" : "Collapsed")) + .accessibilityIdentifier("tool.output.toggle." + item.id) + } + } + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("tool.details." + item.id) + } + } + .overlay(alignment: .bottom) { + Rectangle().fill(MapleSurface.border.opacity(0.25)).frame(height: 0.5) + } + .onChange(of: failed, initial: true) { _, value in if value { expanded = true } } + .onChange(of: item) { _, _ in detailsRevision = UUID() } + .onChange(of: result) { _, _ in detailsRevision = UUID() } + .task(id: detailsRevision) { + let item = item, result = result, locale = Locale.current + let work = Task.detached(priority: .userInitiated) { + let details = ChatToolPresentation.details(item: item, result: result) + let title = + ChatTranscriptRow.isToolOutput(item) + ? MapleStrings.text("Tool result", locale: locale) + : ChatToolPresentation.title( + name: item.name, arguments: item.arguments, + webSearch: item.type == "web_search_call", locale: locale) + return (details, title, ChatToolPresentation.preview(details.output)) + } + let (value, title, preview) = await work.value + if !Task.isCancelled { + details = value; preparedTitle = title; outputPreview = preview + if !expanded || value.output.isEmpty { onPrepared() } + } + } + .onChange(of: expanded) { _, value in if !value { onPrepared() } } + } + private var statusLabel: String { + switch status { + case .active: MapleStrings.text("In progress") + case .completed: MapleStrings.text("Completed") + case .incomplete: MapleStrings.text("Incomplete") + case .failed: MapleStrings.text("Failed") + } + } + private var summary: some View { + HStack(spacing: 8) { + Image( + systemName: ChatToolPresentation.kind(name: item.name, webSearch: item.type == "web_search_call") + .systemImage + ) + .foregroundStyle(.secondary) + Text(verbatim: title).foregroundStyle(.primary).lineLimit(1) + Spacer(minLength: 8) + Text(statusLabel).font(.caption).foregroundStyle( + failed ? AnyShapeStyle(MapleRedText()) : AnyShapeStyle(.secondary)) + if status == .active { + ProgressView().controlSize(.mini) + } else { + Image(systemName: status == .completed ? "checkmark" : "xmark") + .foregroundStyle(failed ? AnyShapeStyle(MapleRedText()) : AnyShapeStyle(.secondary)) + } + if details.hasDetails { + Image(systemName: expanded ? "chevron.down" : "chevron.right") + .font(.caption.weight(.semibold)).foregroundStyle(.secondary) + } + } + .font(.footnote.weight(.medium)) + .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading).contentShape(Rectangle()) + .accessibilityElement(children: .combine) + } + private var title: String { + if let preparedTitle { return preparedTitle } + if ChatTranscriptRow.isToolOutput(item) { return MapleStrings.text("Tool result") } + return ChatToolPresentation.title( + name: item.name, arguments: nil, webSearch: item.type == "web_search_call") + } + } + + /// Observe only this composer's window. Focus and account admission remain + /// with ChatDetail; this adapter reports geometry without changing input. + private struct ComposerKeyboardVisibility: UIViewRepresentable { + let eligible: Bool + let changed: (Bool) -> Void + func makeUIView(context: Context) -> KeyboardVisibilityView { KeyboardVisibilityView() } + func updateUIView(_ view: KeyboardVisibilityView, context: Context) { + view.eligible = eligible; view.changed = changed + } + static func dismantleUIView(_ view: KeyboardVisibilityView, coordinator: ()) { view.invalidate() } + } + + private final class KeyboardVisibilityView: UIView { + var eligible = false + var changed: ((Bool) -> Void)? + override init(frame: CGRect) { + super.init(frame: frame) + isUserInteractionEnabled = false; isAccessibilityElement = false + NotificationCenter.default.addObserver( + self, selector: #selector(keyboardChanged), name: UIResponder.keyboardDidChangeFrameNotification, + object: nil) + NotificationCenter.default.addObserver( + self, selector: #selector(keyboardHidden), name: UIResponder.keyboardDidHideNotification, object: nil) + } + convenience init() { self.init(frame: .zero) } + required init?(coder: NSCoder) { nil } + func invalidate() { + eligible = false; changed = nil; NotificationCenter.default.removeObserver(self) + } + @objc private func keyboardChanged(_ notification: Notification) { + guard eligible, let window, let scene = window.windowScene, scene.activationState == .foregroundActive, + notification.userInfo?[UIResponder.keyboardIsLocalUserInfoKey] as? Bool != false, + let frame = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue, + frame.origin.x.isFinite, frame.origin.y.isFinite, frame.width.isFinite, frame.height.isFinite + else { return } + let overlap = window.bounds.intersection(window.convert(frame, from: scene.screen.coordinateSpace)) + changed?(!overlap.isNull && overlap.width > 0 && overlap.height > 0) + } + @objc private func keyboardHidden(_ notification: Notification) { + guard eligible, let window, window.windowScene?.activationState == .foregroundActive, + notification.userInfo?[UIResponder.keyboardIsLocalUserInfoKey] as? Bool != false + else { return } + changed?(false) + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Chat/ChatToolPresentation.swift b/apps/maple-ios/Sources/MapleAppUI/Chat/ChatToolPresentation.swift new file mode 100644 index 000000000..87da7e0e8 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Chat/ChatToolPresentation.swift @@ -0,0 +1,202 @@ +import Foundation +import MapleSDK + +enum ChatToolPresentation { + enum Kind: Equatable, Sendable { + case shell, fileRead, fileWrite, web, mcp, generic + + var systemImage: String { + switch self { + case .shell: "terminal" + case .fileRead: "doc.text.magnifyingglass" + case .fileWrite: "square.and.pencil" + case .web: "globe" + case .mcp: "square.stack.3d.up" + case .generic: "wrench" + } + } + } + + enum Status: String, Equatable, Sendable { + case active = "In progress", completed = "Completed", incomplete = "Incomplete", failed = "Failed" + } + struct Details: Equatable, Sendable { + let input: String + let output: String + var hasDetails: Bool { !input.isEmpty || !output.isEmpty } + } + + /// Presentation of stored call names only; it does not enable new tools. + static func kind(name: String?, webSearch: Bool = false) -> Kind { + if webSearch { return .web } + var name = name ?? "" + if let separator = name.range(of: "__") { + if name[.. JSONValue? { + guard case .string(let text) = value else { return value } + guard text.utf8.count <= 1_048_576 else { return value } + return (try? JSONDecoder().decode(JSONValue.self, from: Data(text.utf8))) ?? value + } + + static func title(name: String?, arguments value: JSONValue?, webSearch: Bool = false, locale: Locale = .current) + -> String + { + let name = webSearch ? "web_search" : (name ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let value = arguments(value) + func string(_ key: String) -> String? { + guard case .object(let object) = value, case .string(let text) = object[key], + !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { return nil } + return text + } + let query = string("query") + switch name { + case "web_search": + guard let query else { + return String( + localized: "chat.tool-search-name", defaultValue: "Web Search", + table: "Localizable", bundle: .module, locale: locale) + } + return String( + localized: "chat.tool-search-title", defaultValue: "Web Search: \"\(query)\"", + table: "Localizable", bundle: .module, locale: locale) + case "open_url": + guard let url = string("url") else { return MapleStrings.text("Open URL", locale: locale) } + return String( + localized: "chat.tool-url-title", defaultValue: "Open URL: \(url)", + table: "Localizable", bundle: .module, locale: locale) + case "open_urls": + var urls: [String] = [] + if case .object(let object) = value, case .array(let values) = object["urls"] { + urls = values.compactMap { + guard case .string(let url) = $0, !url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { return nil } + return url + } + } + if urls.count == 1 { + return String( + localized: "chat.tool-url-title", defaultValue: "Open URL: \(urls[0])", + table: "Localizable", bundle: .module, locale: locale) + } + guard !urls.isEmpty else { return MapleStrings.text("Open URLs", locale: locale) } + return String( + localized: "chat.tool-urls-title", defaultValue: "Open URLs: \(urls.count) pages", + table: "Localizable", bundle: .module, locale: locale) + case "read_image": + guard case .object(let object) = value else { return MapleStrings.text("Read image", locale: locale) } + let number: Double + switch object["image_number"] { + case .integer(let integer): number = Double(integer) + case .number(let decimal) where decimal.isFinite: number = decimal + default: return MapleStrings.text("Read image", locale: locale) + } + let image = number.formatted(.number.grouping(.never).locale(locale)) + return String( + localized: "chat.tool-image-title", defaultValue: "Read image: Image \(image)", + table: "Localizable", bundle: .module, locale: locale) + case "", "function": return MapleStrings.text("Tool call", locale: locale) + default: + guard let query else { return name } + return String( + localized: "chat.tool-named-query-title", defaultValue: "\(name): \"\(query)\"", + table: "Localizable", bundle: .module, locale: locale) + } + } + + static func status(item: ConversationItem, result: ConversationItem?) -> Status { + if item.type == "web_search_call" { + switch item.status { + case "in_progress", "searching": return .active + case "completed": return .completed + case "incomplete": return .incomplete + default: return .failed + } + } + let statuses = [item.status, result?.status] + if statuses.contains(where: { $0 == "failed" || $0 == "error" }) { return .failed } + if statuses.contains("incomplete") { return .incomplete } + if ChatTranscriptRow.isToolOutput(item) { + return ["in_progress", "streaming"].contains(item.status ?? "") ? .active : .completed + } + if let result { + return ["in_progress", "streaming"].contains(result.status ?? "") ? .active : .completed + } + return .active + } + + static func details(item: ConversationItem, result: ConversationItem?) -> Details { + func formatted(_ value: JSONValue?) -> String { + guard let value else { return "" } + let encoder = JSONEncoder(); encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + guard let bytes = try? encoder.encode(value), bytes.count <= 1_048_576 else { return "" } + return String(decoding: bytes, as: UTF8.self) + } + func output(_ item: ConversationItem?) -> String { + guard let item else { return "" } + if case .string(let text) = item.output { return text.utf8.count <= 1_048_576 ? text : "" } + if item.output != nil { return formatted(item.output) } + let text = (item.content ?? []).compactMap(\.text).joined(separator: "\n\n") + return text.utf8.count <= 1_048_576 ? text : "" + } + let input: String + if ChatTranscriptRow.isToolOutput(item) || item.type == "web_search_call" { + input = "" + } else if case .string(let text) = item.arguments, arguments(item.arguments) == item.arguments { + input = + text.utf8.count <= 1_048_576 && !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? text : "" + } else { + input = formatted(arguments(item.arguments)) + } + return Details( + input: input, output: [output(item), output(result)].filter { !$0.isEmpty }.joined(separator: "\n\n")) + } + + /// Research's 150-character preview retains full destinations when it cuts + /// a plain URL, and stops before a Markdown link rather than breaking it. + static func preview(_ text: String, limit: Int = 150) -> String { + let source = text as NSString + guard source.length > limit, limit > 0 else { return text } + let fullRange = NSRange(location: 0, length: source.length) + let markdown = + (try? NSRegularExpression(pattern: "\\[([^\\]]*)\\]\\(([^)]+)\\)"))? + .matches(in: text, range: fullRange).map(\.range) ?? [] + let urls = + (try? NSRegularExpression(pattern: "https?://[^\\s\\]<>]+"))? + .matches(in: text, range: fullRange).map(\.range) ?? [] + for range in urls where !markdown.contains(where: { NSLocationInRange(range.location, $0) }) { + if limit > range.location, limit < NSMaxRange(range) { + let before = source.substring(to: range.location), url = source.substring(with: range) + let display = prefix(source.substring(with: range), utf16Length: limit - range.location) + return before + "[" + display + "...](" + url + ")" + } + } + for range in markdown where limit > range.location && limit < NSMaxRange(range) { + return source.substring(to: range.location).trimmingCharacters(in: .whitespacesAndNewlines) + "..." + } + return prefix(text, utf16Length: limit) + "..." + } + + private static func prefix(_ text: String, utf16Length: Int) -> String { + var length = min(utf16Length, text.utf16.count) + while length > 0 { + if let range = Range(NSRange(location: 0, length: length), in: text) { return String(text[range]) } + length -= 1 + } + return "" + } +} diff --git a/apps/maple-ios/Sources/MapleAppUI/Chat/ChatWorkflowFixtureView.swift b/apps/maple-ios/Sources/MapleAppUI/Chat/ChatWorkflowFixtureView.swift new file mode 100644 index 000000000..60053fdd1 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Chat/ChatWorkflowFixtureView.swift @@ -0,0 +1,380 @@ +#if os(iOS) && DEBUG + import Foundation + import MapleAppCore + import MapleSDK + import Observation + import SwiftUI + import UIKit + + /// Synthetic service, real protected persistence/runtime/input/queue review. + /// The isolated fixture namespace intentionally survives process termination. + struct ChatWorkflowFixtureView: View { + @State private var model = ChatWorkflowFixtureModel() + @State private var focused = false + @State private var reviewing = false + @State private var away = false + @Environment(\.scenePhase) private var scenePhase + private var lifecycleProof: Bool { + ProcessInfo.processInfo.arguments.contains("--maple-chat-workflow-lifecycle") + } + + var body: some View { + Group { + if let runtime = model.runtime { + VStack { + Button(away ? "Return to chat" : "Visit another screen") { away.toggle() } + .accessibilityIdentifier("chat.fixture.navigate") + if away { + Text("Other screen; retained chat continues").accessibilityIdentifier("chat.fixture.away") + } else { + content(runtime) + } + } + .sheet(isPresented: $reviewing) { QueueReview(runtime: runtime) { focused = true } } + } else { + Text(model.failure ?? "Loading protected fixture…").accessibilityIdentifier("chat.fixture.loading") + } + } + .task { + await model.prepare() + if lifecycleProof { await model.runtime?.reconcileOnLifecycle(foreground: false) } + } + .onChange(of: scenePhase) { _, phase in + guard lifecycleProof, let runtime = model.runtime else { return } + runtime.isForeground = phase == .active + if phase == .active { Task { await runtime.reconcileOnLifecycle(foreground: true) } } + } + .onChange(of: away) { _, hidden in + guard lifecycleProof, let runtime = model.runtime else { return } + runtime.isVisible = !hidden + if !hidden { Task { await runtime.reconcileOnLifecycle(foreground: false) } } + } + } + + private func content(_ runtime: ChatRuntime) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text("Protected chat workflow").font(.headline) + diagnostics(runtime) + if runtime.isEditingQueue { + HStack { + Text(MapleStrings.text("Editing queued message")) + Button(MapleStrings.text("Cancel")) { runtime.cancelQueueEdit() } + .accessibilityIdentifier("chat.fixture.cancel-edit") + Button(MapleStrings.text("Save changes")) { _ = runtime.saveQueueEdit() } + .disabled(runtime.busyDraftIDs.contains(runtime.snapshot.draft.id)) + .accessibilityIdentifier("queue.save") + }.font(.footnote) + } + if !runtime.isOnline { + Label(MapleStrings.text("Offline — your draft stays here"), systemImage: "wifi.slash") + .accessibilityIdentifier("chat.fixture.offline") + } + if let importer = model.importer { DraftImageStrip(importer: importer) } + ChatRecoveryControls(runtime: runtime) + editor(runtime) + if let dictation = model.dictation { + DictationControls(coordinator: dictation, runtime: runtime) + DictationMicrophoneButton(coordinator: dictation, runtime: runtime) + Text(dictation.status(for: runtime)?.phase.rawValue ?? "idle") + .accessibilityIdentifier("chat.fixture.dictation-phase") + } + HStack { + Button(MapleStrings.text("Send")) { _ = runtime.send() } + .disabled(!runtime.canSend).accessibilityIdentifier("chat.send") + if runtime.hasUnresolvedRun { + Button(MapleStrings.text("Stop")) { runtime.stop() }.accessibilityIdentifier("chat.stop") + } + Button(MapleStrings.text("Queued messages")) { reviewing = true } + .accessibilityIdentifier("queue.open") + }.buttonStyle(.bordered).font(.caption) + HStack { + Button("Save checkpoint") { Task { await model.checkpoint() } } + .accessibilityIdentifier("chat.fixture.checkpoint") + Text(model.checkpointState).accessibilityIdentifier("chat.fixture.saved") + Button(runtime.isOnline ? "Go offline" : "Reconnect") { runtime.isOnline.toggle() } + .accessibilityIdentifier("chat.fixture.connectivity") + }.font(.caption) + HStack { + Button("Break before ID") { model.service?.interrupt() } + .accessibilityIdentifier("chat.fixture.interrupt") + Button("Allow recovery") { model.service?.recoveryAvailable = true } + .accessibilityIdentifier("chat.fixture.allow-recovery") + Button("Finish response") { model.service?.deliverCompleted() } + .accessibilityIdentifier("chat.fixture.complete") + Button("Deliver created") { model.service?.deliverCreated() } + .accessibilityIdentifier("chat.fixture.created") + }.font(.caption) + if ProcessInfo.processInfo.arguments.contains("--maple-chat-workflow-context") { + HStack { + Button("Hold next context rejection") { model.service?.holdNextContextRejection() } + .accessibilityIdentifier("chat.fixture.hold-context") + Button("Return context rejection") { model.service?.returnContextRejection() } + .accessibilityIdentifier("chat.fixture.reject-context") + }.font(.caption) + Text(model.service?.contextRejectionHeld == true ? "held" : "idle") + .accessibilityIdentifier("chat.fixture.context-gate") + Text(runtime.snapshot.unsentTurn?.text ?? "none") + .accessibilityIdentifier("chat.fixture.unsent") + } + ForEach(runtime.messages, id: \.id) { item in + Text(item.content?.compactMap(\.text).joined(separator: " ") ?? "") + .accessibilityIdentifier("chat.fixture.message." + item.id) + } + }.padding() + } + .onChange(of: runtime.snapshot) { _, _ in model.checkpointState = "unsaved" } + } + + private func diagnostics(_ runtime: ChatRuntime) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text( + "Composer images \(runtime.snapshot.draft.images.count); held images \(runtime.snapshot.queue?.edit?.heldDraft.images.count ?? 0); queued images \(runtime.queuedTurns.first?.draft.images.count ?? 0)" + ) + .accessibilityIdentifier("chat.fixture.image-counts") + Text(imageIDs(runtime.snapshot.draft.images)) + .accessibilityIdentifier("chat.fixture.composer-image-ids") + Text(imageIDs(runtime.snapshot.queue?.edit?.heldDraft.images ?? [])) + .accessibilityIdentifier("chat.fixture.held-image-ids") + Text(imageIDs(runtime.queuedTurns.first?.draft.images ?? [])) + .accessibilityIdentifier("chat.fixture.queued-image-ids") + Text(runtime.phase?.rawValue ?? "idle").accessibilityIdentifier("chat.fixture.phase") + Text(runtime.isQueuePaused ? "paused" : "running").accessibilityIdentifier("chat.fixture.queue-state") + Text("Queued turns \(runtime.queuedTurns.count)").accessibilityIdentifier("chat.fixture.queue-count") + Text(runtime.snapshot.queue?.edit?.heldDraft.text ?? "no held draft") + .accessibilityIdentifier("chat.fixture.held") + Text(runtime.queuedTurns.map(\.draft.text).joined(separator: " | ")) + .accessibilityIdentifier("chat.fixture.queued") + Text(runtime.snapshot.pendingRun?.messageID ?? "none").accessibilityIdentifier( + "chat.fixture.message-id") + Text("POSTs \(model.service?.posts ?? 0)").accessibilityIdentifier("chat.fixture.posts") + Text("Exact lookups \(model.service?.lookups ?? 0)").accessibilityIdentifier("chat.fixture.lookups") + Text("Cancels \(model.service?.cancels ?? 0)").accessibilityIdentifier("chat.fixture.cancels") + Text(model.service?.lastLookup ?? "none").accessibilityIdentifier("chat.fixture.last-lookup") + Text(model.service?.durableBeforePOST == true ? "durable" : "no dispatch") + .accessibilityIdentifier("chat.fixture.durability") + Text(runtime.lastIssue?.rawValue ?? "none").accessibilityIdentifier("chat.fixture.issue") + }.font(.caption2) + } + + private func imageIDs(_ images: [ChatImageAttachment]) -> String { + images.isEmpty ? "none" : images.map { $0.id.uuidString }.joined(separator: ",") + } + + private func editor(_ runtime: ChatRuntime) -> some View { + let owner = runtime.snapshot.draft.id + return ComposerEditor( + text: Binding( + get: { runtime.ownedDraft(id: owner)?.text ?? "" }, + set: { text in + guard var draft = runtime.ownedDraft(id: owner) else { return } + draft.text = text; _ = runtime.updateOwnedDraft(draft) + }), focused: $focused, ownerID: owner, accountGeneration: runtime.scope.generation, + placeholder: "Synthetic composer", accessibilityIdentifier: "chat.fixture.composer", + externalSelection: model.dictation?.editorState(runtime: runtime, draftID: owner), + onChange: { state, edits in + model.dictation?.updateEditor(runtime: runtime, draftID: owner, state: state, edits: edits) + }, + onSubmit: { + guard runtime.snapshot.draft.id == owner else { return } + _ = runtime.send() + } + ).frame(minHeight: 72).padding(8).background(.quaternary, in: RoundedRectangle(cornerRadius: 8)) + } + } + + @MainActor @Observable private final class ChatWorkflowFixtureModel { + private let scope = AccountScope( + apiOrigin: "http://127.0.0.1:1234", principalID: "native-chat-workflow-fixture") + private var store: ProtectedChatStore? + private(set) var service: ChatWorkflowFixtureService? + private(set) var runtime: ChatRuntime? + private(set) var importer: ImageImportController? + private(set) var dictation: DictationCoordinator? + private(set) var failure: String? + var checkpointState = "unsaved" + private var preparing = false + static let conversation = "native-workflow-conversation" + + func prepare() async { + guard !preparing, runtime == nil else { return }; preparing = true + defer { preparing = false } + do { + let support = try FileManager.default.url( + for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + let store = ProtectedChatStore( + root: support.appendingPathComponent("NativeChatWorkflowFixture", isDirectory: true)) + try await store.activate(scope) + if ProcessInfo.processInfo.arguments.contains("--maple-chat-workflow-reset") { + try await store.remove(chatID: Self.conversation, scope: scope) + } + var snapshot = try await store.load(chatID: Self.conversation, scope: scope) + if snapshot == nil { + let empty = ProcessInfo.processInfo.arguments.contains("--maple-chat-workflow-empty") + let images = ProcessInfo.processInfo.arguments.contains("--maple-chat-workflow-images") + let queue = + empty + ? nil + : ChatQueueState( + items: [ + ChatQueuedTurn( + draft: ChatDraft(text: "Queued A", images: images ? [Self.image(.systemBlue)] : [])) + ], paused: true) + let initial = ChatSnapshot( + storageKey: Self.conversation, conversationID: Self.conversation, + draft: ChatDraft( + text: "Held draft B", + images: images ? [Self.image(.systemOrange), Self.image(.systemGreen)] : []), queue: queue) + try await store.save(initial, scope: scope); snapshot = initial + } + guard let snapshot else { throw ChatStorageError.corruptData } + let service = ChatWorkflowFixtureService( + store: store, scope: scope, expectedMessageID: snapshot.pendingRun?.messageID) + let runtime = ChatRuntime( + scope: scope, service: service, persistence: store, conversationID: Self.conversation) + guard runtime.loadRetainedSnapshot(snapshot) else { throw ChatStorageError.corruptData } + runtime.isVisible = true + self.store = store; self.service = service; self.runtime = runtime + importer = ImageImportController(runtime: runtime, processor: ImageImportProcessor()) + if ProcessInfo.processInfo.arguments.contains("--maple-chat-workflow-dictation") { + let dictation = DictationCoordinator( + scope: scope, capture: LocalLifecycleCapture(), transcriber: LocalLifecycleTranscriber()) + runtime.onDraftOwnerChanging = { [weak dictation, weak runtime] id in + guard let runtime else { return } + dictation?.ownerWillChange(runtime: runtime, draftID: id) + } + runtime.onDraftOwnerRetired = { [weak dictation, weak runtime] id in + guard let runtime else { return } + dictation?.ownerRetired(runtime: runtime, draftID: id) + } + self.dictation = dictation + } + + } catch { failure = "Protected fixture could not load" } + } + + private static func image(_ color: UIColor) -> ChatImageAttachment { + let data = UIGraphicsImageRenderer(size: CGSize(width: 64, height: 64)).pngData { context in + color.setFill(); context.fill(CGRect(x: 0, y: 0, width: 64, height: 64)) + } + return ChatImageAttachment(data: data, contentType: "image/png") + } + + func checkpoint() async { + guard let runtime, let store else { return } + await runtime.saveDraft() + do { + let saved = try await store.load(chatID: Self.conversation, scope: scope) + checkpointState = saved == runtime.snapshot ? "saved" : "unsaved" + } catch { checkpointState = "unsaved" } + } + } + + @MainActor @Observable private final class ChatWorkflowFixtureService: ChatServing { + private let store: ProtectedChatStore + private let scope: AccountScope + private var expectedMessageID: String? + private var continuation: AsyncThrowingStream.Continuation? + private let responseID = "native-workflow-response" + var recoveryAvailable = false + private(set) var posts = 0 + private(set) var lookups = 0 + private(set) var cancels = 0 + private(set) var lastLookup: String? + private(set) var durableBeforePOST = false + @ObservationIgnored private var rejectNextContext = false + @ObservationIgnored private var contextWaiter: CheckedContinuation? + private(set) var contextRejectionHeld = false + + init(store: ProtectedChatStore, scope: AccountScope, expectedMessageID: String?) { + self.store = store; self.scope = scope; self.expectedMessageID = expectedMessageID + } + func createConversation(metadata: [String: JSONValue]?, projectId: String?, pinned: Bool?) async throws + -> Conversation + { + throw ResearchAPIError.invalidArgument + } + func listConversations(_ parameters: ConversationsListParameters) async throws -> APIPage { + APIPage(data: []) + } + func createResponse(_ request: ResponsesCreateRequest, maximumInferenceSends: Int) async throws + -> AsyncThrowingStream + { + guard maximumInferenceSends == 2, case .some(.string(let id)) = request.metadata?["internal_message_id"] + else { + throw ResearchAPIError.invalidArgument + } + let saved = try await store.load(chatID: request.conversation, scope: scope) + durableBeforePOST = saved?.pendingRun?.messageID == id && saved?.pendingRun?.dispatched == true + guard durableBeforePOST else { throw ResearchAPIError.invalidArgument } + posts += 1; expectedMessageID = id + if rejectNextContext { + rejectNextContext = false; contextRejectionHeld = true + await withCheckedContinuation { contextWaiter = $0 } + contextRejectionHeld = false + throw ResearchAPIError.http(status: 413, reason: .contextLimit) + } + let pair = AsyncThrowingStream.makeStream() + continuation = pair.continuation; return pair.stream + } + func getConversationItem(conversationId: String, itemId: String) async throws -> ConversationItem { + lookups += 1; lastLookup = itemId + guard recoveryAvailable, conversationId == ChatWorkflowFixtureModel.conversation, + itemId == expectedMessageID + else { + throw ResearchAPIError.http(status: 404, reason: .notFound) + } + return ConversationItem( + id: itemId, responseId: responseID, type: "message", role: "user", + content: [.init(type: "input_text", text: "Held draft B")]) + } + func retrieveResponse(id: String) async throws -> ResearchResponse { + guard id == responseID else { throw ResearchAPIError.invalidArgument } + return response(.completed) + } + func cancelResponse(id: String) async throws -> ResearchResponse { + guard id == responseID else { throw ResearchAPIError.invalidArgument } + cancels += 1; return response(.cancelled) + } + func listConversationItems(conversationId: String, parameters: ListParameters) async throws -> APIPage< + ConversationItem + > { APIPage(data: []) } + func holdNextContextRejection() { rejectNextContext = true } + func returnContextRejection() { + let waiter = contextWaiter; contextWaiter = nil; waiter?.resume() + } + func interrupt() { continuation?.finish(throwing: ResearchAPIError.truncatedStream); continuation = nil } + func deliverCreated() { + struct Wrapper: Encodable { let response: ResearchResponse } + do { + let encoder = JSONEncoder(); encoder.keyEncodingStrategy = .convertToSnakeCase + let decoder = JSONDecoder(); decoder.keyDecodingStrategy = .convertFromSnakeCase + let event = try decoder.decode( + ResponseLifecycleEvent.self, from: encoder.encode(Wrapper(response: response(.inProgress)))) + continuation?.yield(.created(event)) + } catch { continuation?.finish(throwing: ResearchAPIError.invalidResponse) } + } + func deliverCompleted() { + struct Wrapper: Encodable { let response: ResearchResponse } + do { + let encoder = JSONEncoder(); encoder.keyEncodingStrategy = .convertToSnakeCase + let decoder = JSONDecoder(); decoder.keyDecodingStrategy = .convertFromSnakeCase + let event = try decoder.decode( + ResponseLifecycleEvent.self, from: encoder.encode(Wrapper(response: response(.completed)))) + continuation?.yield(.completed(event)); continuation?.finish(); continuation = nil + } catch { continuation?.finish(throwing: ResearchAPIError.invalidResponse) } + } + + private func response(_ status: ResponseStatus) -> ResearchResponse { + ResearchResponse( + id: responseID, object: "response", createdAt: 0, status: status, model: "auto:quick", + output: status == .completed + ? [ + ConversationItem( + id: "recovered-assistant", responseId: responseID, type: "message", status: "completed", + role: "assistant", + content: [.init(type: "output_text", text: "Recovered without replay")]) + ] : nil) + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Chat/QueueReview.swift b/apps/maple-ios/Sources/MapleAppUI/Chat/QueueReview.swift new file mode 100644 index 000000000..5b3499bdd --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Chat/QueueReview.swift @@ -0,0 +1,52 @@ +#if os(iOS) + import MapleAppCore + import SwiftUI + + struct QueueReview: View { + @Bindable var runtime: ChatRuntime + let onEdit: () -> Void + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + Section { + if runtime.isQueuePaused { + Text(MapleStrings.text("Queued messages are paused. Resume when you’re ready.")) + .foregroundStyle(.secondary) + Button(MapleStrings.text("Resume queue")) { _ = runtime.resumeQueue() } + .disabled(!runtime.isOnline || runtime.isEditingQueue) + .accessibilityIdentifier("queue.resume") + } else { + Button(MapleStrings.text("Pause queue")) { runtime.pauseQueue() }.accessibilityIdentifier( + "queue.pause") + } + } + ForEach(runtime.queuedTurns) { turn in + VStack(alignment: .leading, spacing: 10) { + if !turn.draft.text.isEmpty { Text(turn.draft.text).lineLimit(3) } + if !turn.draft.images.isEmpty { + Text(MapleStrings.imageCount(turn.draft.images.count)).font(.footnote) + } + HStack { + Button(MapleStrings.text("Edit")) { + if runtime.beginQueueEdit(id: turn.id) { dismiss(); onEdit() } + }.disabled(runtime.isEditingQueue).buttonStyle(.bordered) + Button(MapleStrings.text("Delete"), role: .destructive) { + runtime.deleteQueuedTurn(id: turn.id) + } + .buttonStyle(.bordered) + } + }.padding(.vertical, 4) + } + } + .listStyle(.plain) + .listRowBackground(Color.clear) + .scrollContentBackground(.hidden) + .background(MapleSurface.canvas) + .navigationTitle(MapleStrings.text("Queued messages")).navigationBarTitleDisplayMode(.inline) + .toolbar { ToolbarItem(placement: .topBarTrailing) { Button(MapleStrings.text("Done")) { dismiss() } } } + } + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Chat/TeamSeatMismatchPresentation.swift b/apps/maple-ios/Sources/MapleAppUI/Chat/TeamSeatMismatchPresentation.swift new file mode 100644 index 000000000..7db61cd94 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Chat/TeamSeatMismatchPresentation.swift @@ -0,0 +1,89 @@ +import Foundation +import MapleAppCore + +/// Research's mobile seat-mismatch copy. Counts may be present without proving +/// the mismatch, so the server's flag still uses generic copy in that case. +struct TeamSeatMismatchPresentation: Equatable, Sendable { + enum Audience: Equatable, Sendable { case admin, member } + struct ExactCounts: Equatable, Sendable { + let members: Int + let paidSeats: Int + } + + let audience: Audience + let exactCounts: ExactCounts? + + init?(status: TeamStatus?) { + guard let status, let mismatch = status.seatMismatch else { return nil } + audience = status.role == "admin" || status.isTeamAdmin == true ? .admin : .member + if let members = mismatch.memberCount, let paidSeats = mismatch.billedSeatCount, + members > paidSeats + { + exactCounts = ExactCounts(members: members, paidSeats: paidSeats) + } else { + exactCounts = nil + } + } + + func summary(locale: Locale = .current) -> String { + guard let exactCounts else { return MapleStrings.text("Seat count mismatch", locale: locale) } + return String( + localized: "team.seat-mismatch-summary", + defaultValue: summaryDefault(exactCounts), + table: "Localizable", bundle: .module, locale: locale) + } + + func message(locale: Locale = .current) -> String { + switch (audience, exactCounts) { + case (.admin, .some(let counts)): + String( + localized: "team.seat-mismatch-admin-message", + defaultValue: messageDefault(counts), + table: "Localizable", bundle: .module, locale: locale) + case (.member, .some(let counts)): + String( + localized: "team.seat-mismatch-member-message", + defaultValue: messageDefault(counts), + table: "Localizable", bundle: .module, locale: locale) + case (.admin, .none): + MapleStrings.text( + "This team has more members than paid seats. Remove members to match the paid seat count and resume team usage.", + locale: locale) + case (.member, .none): + MapleStrings.text( + "This team has more members than paid seats. Contact your team admin to resolve the seat count.", + locale: locale) + } + } + + // Package consumers can fall back before a catalog is compiled. Keep English + // grammar correct while retaining both typed count arguments for the native + // catalog's independent plural substitutions. Exact counts require members + // to exceed paid seats, leaving these three possible singular/plural pairs. + private func summaryDefault(_ counts: ExactCounts) -> String.LocalizationValue { + if counts.members == 1 { + return "\(counts.members) member, \(counts.paidSeats) paid seats" + } + if counts.paidSeats == 1 { + return "\(counts.members) members, \(counts.paidSeats) paid seat" + } + return "\(counts.members) members, \(counts.paidSeats) paid seats" + } + + private func messageDefault(_ counts: ExactCounts) -> String.LocalizationValue { + switch (audience, counts.members == 1, counts.paidSeats == 1) { + case (.admin, true, _): + "This team has \(counts.members) member but only \(counts.paidSeats) paid seats. Remove members to match the paid seat count and resume team usage." + case (.admin, false, true): + "This team has \(counts.members) members but only \(counts.paidSeats) paid seat. Remove members to match the paid seat count and resume team usage." + case (.admin, false, false): + "This team has \(counts.members) members but only \(counts.paidSeats) paid seats. Remove members to match the paid seat count and resume team usage." + case (.member, true, _): + "This team has \(counts.members) member but only \(counts.paidSeats) paid seats. Contact your team admin to resolve the seat count." + case (.member, false, true): + "This team has \(counts.members) members but only \(counts.paidSeats) paid seat. Contact your team admin to resolve the seat count." + case (.member, false, false): + "This team has \(counts.members) members but only \(counts.paidSeats) paid seats. Contact your team admin to resolve the seat count." + } + } +} diff --git a/apps/maple-ios/Sources/MapleAppUI/Chat/TranscriptHistoryPagination.swift b/apps/maple-ios/Sources/MapleAppUI/Chat/TranscriptHistoryPagination.swift new file mode 100644 index 000000000..592843070 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Chat/TranscriptHistoryPagination.swift @@ -0,0 +1,55 @@ +import Foundation + +/// Only an actual backward reader gesture can load one older history page. +/// A second gesture at the old boundary can queue one page while the owned +/// prepend settles. Layout and the first gesture's momentum never queue it. +struct TranscriptHistoryPagination { + private var activeGesture = false + private var consumed = false + private(set) var isLoadInFlight = false + private var queued = false + + mutating func gestureChanged(active: Bool) { + if active && !activeGesture { consumed = false } + activeGesture = active + } + + mutating func shouldLoad( + previousOffset: CGFloat, offset: CGFloat, canLoad: Bool, requestInFlight: Bool, + directUserInput: Bool = true + ) + -> Bool + { + guard previousOffset.isFinite, offset.isFinite else { return false } + if activeGesture, directUserInput, offset > 100 || offset - previousOffset > 2 { queued = false } + if !canLoad { queued = false; return false } + guard activeGesture, !consumed, + previousOffset - offset > 2, offset <= 100 + else { return false } + consumed = true + if isLoadInFlight { queued = true; return false } + // Another projection's request is not owned by this gate and cannot + // turn a discarded gesture into an automatic post-failure retry. + guard !requestInFlight else { return false } + isLoadInFlight = true + return true + } + + mutating func finishLoad(preserveQueued: Bool = false) { + isLoadInFlight = false + if !preserveQueued { queued = false } + consumed = true + } + + mutating func startQueuedLoad(canLoad: Bool) -> Bool { + guard queued, !isLoadInFlight else { return false } + queued = false + guard canLoad else { return false } + isLoadInFlight = true + return true + } + + mutating func resetIntent() { + activeGesture = false; consumed = false; queued = false + } +} diff --git a/apps/maple-ios/Sources/MapleAppUI/Chat/TranscriptScrollPolicy.swift b/apps/maple-ios/Sources/MapleAppUI/Chat/TranscriptScrollPolicy.swift new file mode 100644 index 000000000..ebc559f17 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Chat/TranscriptScrollPolicy.swift @@ -0,0 +1,76 @@ +import Foundation + +struct TranscriptViewportMetrics: Codable, Equatable { + let offset: CGFloat + let distanceFromBottom: CGFloat + let viewportHeight: CGFloat + let rawOffset: CGFloat + let topInset: CGFloat + let bottomInset: CGFloat + let contentHeight: CGFloat + let visibleMinY: CGFloat + let visibleHeight: CGFloat + let visibleMaxY: CGFloat + let boundsMinY: CGFloat + let boundsHeight: CGFloat +} + +/// Research's UnifiedChat follows appended assistant items when the reader is +/// within 100 points of the bottom, then nudges once when a reply starts. Token +/// deltas and completion do not move the reader's current position. +struct TranscriptScrollPolicy { + enum Request: Equatable { case appendedItem, streamingStarted } + struct Mutation: Equatable { + let lastItemID: String? + let lastItemIsAssistant: Bool + let hasStreamingMessage: Bool + } + + private(set) var isUserScrolling = false + private var previous: Mutation? + + mutating func receive(_ mutation: Mutation) -> [Request] { + defer { previous = mutation } + // Loading the first history page is a projection, not an append. + guard let previous, previous.lastItemID != nil else { return [] } + var requests: [Request] = [] + if mutation.lastItemID != nil, mutation.lastItemID != previous.lastItemID, + mutation.lastItemIsAssistant, !isUserScrolling + { + requests.append(.appendedItem) + } + if mutation.hasStreamingMessage, !previous.hasStreamingMessage, !isUserScrolling { + requests.append(.streamingStarted) + } + return requests + } + + mutating func readerScrolled(distanceFromBottom: CGFloat) { + guard distanceFromBottom.isFinite else { return } + isUserScrolling = distanceFromBottom >= 100 + } + + static func maximumOffset(contentHeight: CGFloat, viewportHeight: CGFloat) -> CGFloat? { + guard [contentHeight, viewportHeight].allSatisfy(\.isFinite), + contentHeight >= 0, viewportHeight > 0 + else { return nil } + // ScrollGeometry.containerSize already excludes the safe-area insets. + // Targets use content coordinates; contentOffset + topInset normalizes + // the reader into those same coordinates. Adding the insets again + // falsely reports distance remaining even at the physical scroll end. + return max(0, contentHeight - viewportHeight) + } + + static func streamStartOffset(current: CGFloat, maximum: CGFloat) -> CGFloat? { + guard current.isFinite, maximum.isFinite else { return nil } + return min(max(0, current) + 100, max(0, maximum)) + } + + static func appendedItemOffset( + expectedItemID: String, renderedItemID: String?, tailBottom: CGFloat, + viewportHeight: CGFloat + ) -> CGFloat? { + guard expectedItemID == renderedItemID else { return nil } + return maximumOffset(contentHeight: tailBottom + 20, viewportHeight: viewportHeight) + } +} diff --git a/apps/maple-ios/Sources/MapleAppUI/Chat/TranscriptVerticalAxisGuard.swift b/apps/maple-ios/Sources/MapleAppUI/Chat/TranscriptVerticalAxisGuard.swift new file mode 100644 index 000000000..9067f1c27 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Chat/TranscriptVerticalAxisGuard.swift @@ -0,0 +1,87 @@ +#if os(iOS) + import SwiftUI + import UIKit + + /// SwiftUI can move a vertical scroll view sideways when a nested rich block + /// grows. Keep only the owning transcript's horizontal offset at its inset. + struct TranscriptVerticalAxisGuard: UIViewRepresentable { + let active: Bool + @Environment(\.scenePhase) private var scenePhase + + func makeUIView(context: Context) -> Marker { Marker(frame: .zero) } + + func updateUIView(_ view: Marker, context: Context) { + view.active = active && scenePhase == .active + view.reconcile() + } + + static func dismantleUIView(_ view: Marker, coordinator: ()) { + view.active = false + view.detach() + } + + @MainActor final class Marker: UIView { + var active = false + private weak var scrollView: UIScrollView? + private var observation: NSKeyValueObservation? + private var correcting = false + + override init(frame: CGRect) { + super.init(frame: frame) + isUserInteractionEnabled = false + isAccessibilityElement = false + accessibilityElementsHidden = true + } + + required init?(coder: NSCoder) { nil } + + override func didMoveToSuperview() { super.didMoveToSuperview(); reconcile() } + override func didMoveToWindow() { super.didMoveToWindow(); reconcile() } + override func layoutSubviews() { super.layoutSubviews(); reconcile() } + + private func nearestScrollView() -> UIScrollView? { + var parent = superview + while let view = parent { + if let scroll = view as? UIScrollView { return scroll } + parent = view.superview + } + return nil + } + + func detach() { + observation?.invalidate(); observation = nil; scrollView = nil + } + + func reconcile() { + guard active, let window, window.windowScene?.activationState == .foregroundActive, + let candidate = nearestScrollView(), candidate.window === window + else { detach(); return } + if scrollView !== candidate { + detach(); scrollView = candidate + observation = candidate.observe(\.contentOffset, options: [.new]) { [weak self] _, _ in + if Thread.isMainThread { + MainActor.assumeIsolated { self?.correctHorizontalOffset() } + } else { + Task { @MainActor [weak self] in self?.correctHorizontalOffset() } + } + } + } + correctHorizontalOffset() + } + + private func correctHorizontalOffset() { + guard !correcting, active, let window, + window.windowScene?.activationState == .foregroundActive, + let scrollView, scrollView.window === window, nearestScrollView() === scrollView + else { return } + let current = scrollView.contentOffset + let x = -scrollView.adjustedContentInset.left + guard current.x.isFinite, current.y.isFinite, x.isFinite, abs(current.x - x) > 0.5 else { return } + correcting = true + defer { correcting = false } + // Preserve the current vertical position, including user drags. + scrollView.setContentOffset(CGPoint(x: x, y: current.y), animated: false) + } + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Composer/ComposerControlFlow.swift b/apps/maple-ios/Sources/MapleAppUI/Composer/ComposerControlFlow.swift new file mode 100644 index 000000000..5acaa9832 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Composer/ComposerControlFlow.swift @@ -0,0 +1,289 @@ +import Foundation + +/// The native text, insertion caret and placeholder share one padded surface. +/// Height proposals include that padding without squeezing Dynamic Type text. +enum ComposerEditorMetrics { + static let horizontalInset: CGFloat = 8 + static let verticalInset: CGFloat = 6 + + static func preferredHeight(lineHeight: CGFloat, contentHeight: CGFloat, compact: Bool) -> CGFloat { + let minimum = max(44, lineHeight + verticalInset * 2) + return compact ? minimum : min(max(minimum, contentHeight), max(minimum, lineHeight * 8 + verticalInset * 2)) + } + + static func topInset(height: CGFloat, lineHeight: CGFloat, compact: Bool) -> CGFloat { + compact ? max(verticalInset, (height - lineHeight) / 2) : verticalInset + } +} + +/// Deterministic packing only; measurement and placement never publish view state. +struct ComposerFlowPlan { + let frames: [CGRect?] + let height: CGFloat + + init(sizes: [CGSize], width: CGFloat, spacing: CGFloat = 8, trailingIndices: Set = []) { + let width = max(0, width) + var frames: [CGRect?] = [] + var x: CGFloat = 0, y: CGFloat = 0, rowHeight: CGFloat = 0 + let trailingStart = sizes.indices.first { trailingIndices.contains($0) && sizes[$0].width > 0 } + for (index, size) in sizes.enumerated() { + guard size.width > 0, size.height > 0 else { frames.append(nil); continue } + if index == trailingStart, x > 0 { + let group = sizes.indices.filter { $0 >= index && trailingIndices.contains($0) && sizes[$0].width > 0 } + let groupWidth = + group.reduce(CGFloat.zero) { $0 + sizes[$1].width } + + CGFloat(max(0, group.count - 1)) * spacing + if groupWidth <= width, x + groupWidth > width { + x = 0; y += rowHeight + spacing; rowHeight = 0 + } + } + if x > 0, x + size.width > width { + x = 0; y += rowHeight + spacing; rowHeight = 0 + } + frames.append(CGRect(origin: CGPoint(x: x, y: y), size: size)) + x += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + // The trailing cluster stays together when it fits a row. Move it as a + // unit so microphone and the primary action share the same edge. + if let index = frames.lastIndex(where: { $0 != nil }), trailingIndices.contains(index), + let frame = frames[index] + { + let shift = max(0, width - frame.maxX) + for candidate in frames.indices where trailingIndices.contains(candidate) { + guard var trailing = frames[candidate], trailing.minY == frame.minY else { continue } + trailing.origin.x += shift; frames[candidate] = trailing + } + } + self.frames = frames + height = y + rowHeight + } +} + +/// One editor is measured in either an inline pill or above the same controls. +/// The plan never mutates focus, the draft, or a presentation's identity. +struct ComposerInputPlan { + let frames: [CGRect?] + let height: CGFloat + + init( + sizes: [CGSize], editorIndex: Int, width: CGFloat, expanded: Bool, + hiddenIndices: Set = [], trailingIndices: Set = [], spacing: CGFloat = 4 + ) { + let width = width.isFinite ? max(0, width) : 0 + guard sizes.indices.contains(editorIndex) else { frames = []; height = 0; return } + let visible = sizes.indices.filter { + !hiddenIndices.contains($0) && sizes[$0].width > 0 && sizes[$0].height > 0 + } + let controls = visible.filter { $0 != editorIndex } + let controlWidth = controls.reduce(CGFloat.zero) { $0 + sizes[$1].width } + let inlineWidth = width - controlWidth - CGFloat(max(0, visible.count - 1)) * spacing + var frames = Array(repeating: nil, count: sizes.count) + if !expanded, inlineWidth >= 44 { + let rowHeight = visible.map { sizes[$0].height }.max() ?? 0 + var x: CGFloat = 0 + for index in visible { + let size = CGSize( + width: index == editorIndex ? inlineWidth : sizes[index].width, height: sizes[index].height) + frames[index] = CGRect(x: x, y: (rowHeight - size.height) / 2, width: size.width, height: size.height) + x += size.width + spacing + } + self.frames = frames; height = rowHeight + } else { + let controlSizes = sizes.indices.map { index in + index == editorIndex || hiddenIndices.contains(index) ? CGSize.zero : sizes[index] + } + let flow = ComposerFlowPlan( + sizes: controlSizes, width: width, spacing: spacing, trailingIndices: trailingIndices) + let editorHeight = sizes[editorIndex].height + let gap: CGFloat = flow.height > 0 ? 4 : 0 + frames[editorIndex] = CGRect(x: 0, y: 0, width: width, height: editorHeight) + for index in flow.frames.indices { + frames[index] = + index == editorIndex ? frames[index] : flow.frames[index]?.offsetBy(dx: 0, dy: editorHeight + gap) + } + self.frames = frames; height = editorHeight + gap + flow.height + } + } +} + +#if os(iOS) + import SwiftUI + + struct ComposerEditorLayoutKey: LayoutValueKey { + static let defaultValue = false + } + + enum ComposerInputRole { case attachment, editor, model, project, microphone, action } + struct ComposerInputRoleKey: LayoutValueKey { static let defaultValue = ComposerInputRole.attachment } + + /// Changes positions and finite editor proposals while retaining the exact + /// native input surface through expansion and keyboard dismissal. + struct ComposerInputLayout: Layout { + var expanded: Bool + var layoutDirection: LayoutDirection = .leftToRight + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let width = proposal.width.flatMap { $0.isFinite ? max(0, $0) : nil } ?? 320 + let plan = measuredPlan(width: width, height: proposal.height, subviews: subviews) + return CGSize(width: width, height: plan.height) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let plan = measuredPlan(width: bounds.width, height: bounds.height, subviews: subviews) + for index in subviews.indices { + guard plan.frames.indices.contains(index), let frame = plan.frames[index] else { + subviews[index].place(at: bounds.origin, proposal: .zero) + continue + } + subviews[index].place( + at: CGPoint( + x: layoutDirection == .rightToLeft ? bounds.maxX - frame.minX : bounds.minX + frame.minX, + y: bounds.minY + frame.minY), + anchor: layoutDirection == .rightToLeft ? .topTrailing : .topLeading, + proposal: ProposedViewSize(frame.size)) + } + } + + private func measuredPlan(width: CGFloat, height: CGFloat?, subviews: Subviews) -> ComposerInputPlan { + guard let editor = subviews.indices.first(where: { subviews[$0][ComposerInputRoleKey.self] == .editor }) + else { + return ComposerInputPlan(sizes: [], editorIndex: 0, width: width, expanded: expanded) + } + let hidden = Set( + subviews.indices.filter { + !expanded && subviews[$0][ComposerInputRoleKey.self] == .project + }) + let trailing = Set( + subviews.indices.filter { + [.project, .microphone, .action].contains(subviews[$0][ComposerInputRoleKey.self]) + }) + var sizes = subviews.indices.map { index -> CGSize in + guard index != editor, !hidden.contains(index) else { return .zero } + let natural = subviews[index].sizeThatFits(.unspecified) + guard natural.width > 0, natural.height > 0 else { return .zero } + let controlWidth = min(width, max(44, natural.width)) + let measured = subviews[index].sizeThatFits(ProposedViewSize(width: controlWidth, height: nil)) + return CGSize(width: controlWidth, height: max(44, measured.height)) + } + let controls = sizes.filter { $0.width > 0 && $0.height > 0 } + let inlineWidth = width - controls.reduce(0) { $0 + $1.width } - CGFloat(controls.count) * 4 + let inline = !expanded && inlineWidth >= 44 + let chrome = ComposerFlowPlan(sizes: sizes, width: width, spacing: 4, trailingIndices: trailing).height + let editorHeight = height.flatMap { $0.isFinite ? max(0, $0 - (inline ? 0 : chrome + 4)) : nil } + sizes[editor] = subviews[editor].sizeThatFits( + ProposedViewSize(width: inline ? inlineWidth : width, height: editorHeight)) + return ComposerInputPlan( + sizes: sizes, editorIndex: editor, width: width, expanded: expanded, + hiddenIndices: hidden, trailingIndices: trailing) + } + } + + /// Reserve the existing chrome at its measured size before giving the native + /// scrolling editor its share of the keyboard-reduced viewport. No owners or + /// view state change during measurement or placement. + struct ComposerVerticalLayout: Layout { + var maximumHeight: CGFloat + var layoutDirection: LayoutDirection = .leftToRight + var spacing: CGFloat = 10 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let width = availableWidth(proposal.width, subviews: subviews) + let sizes = measuredSizes(width: width, height: heightLimit(proposal.height), subviews: subviews) + let visible = sizes.filter { $0.height > 0 } + return CGSize( + width: width, + height: visible.reduce(0) { $0 + $1.height } + CGFloat(max(0, visible.count - 1)) * spacing) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let sizes = measuredSizes(width: bounds.width, height: heightLimit(bounds.height), subviews: subviews) + var y = bounds.minY, placedVisible = false + for (index, size) in sizes.enumerated() { + if size.height > 0, placedVisible { y += spacing } + subviews[index].place( + at: CGPoint(x: layoutDirection == .rightToLeft ? bounds.maxX : bounds.minX, y: y), + anchor: layoutDirection == .rightToLeft ? .topTrailing : .topLeading, + proposal: ProposedViewSize(size)) + if size.height > 0 { y += size.height; placedVisible = true } + } + } + + private func measuredSizes(width: CGFloat, height: CGFloat, subviews: Subviews) -> [CGSize] { + var sizes = subviews.map { $0.sizeThatFits(ProposedViewSize(width: width, height: nil)) } + guard let editor = subviews.indices.first(where: { subviews[$0][ComposerEditorLayoutKey.self] }) else { + return sizes + } + let visible = sizes.indices.filter { sizes[$0].height > 0 } + let chrome = visible.filter { $0 != editor }.reduce(CGFloat.zero) { $0 + sizes[$1].height } + let gaps = CGFloat(max(0, visible.count - 1)) * spacing + // The editor subview includes its existing padding and border. + sizes[editor] = subviews[editor].sizeThatFits( + ProposedViewSize(width: width, height: max(0, height - chrome - gaps))) + return sizes + } + + private func heightLimit(_ proposed: CGFloat?) -> CGFloat { + let limit = maximumHeight.isFinite ? max(0, maximumHeight) : 0 + guard let proposed, proposed.isFinite else { return limit } + return min(limit, max(0, proposed)) + } + + private func availableWidth(_ proposed: CGFloat?, subviews: Subviews) -> CGFloat { + if let proposed, proposed.isFinite { return max(0, proposed) } + return subviews.map { $0.sizeThatFits(.unspecified).width }.filter { $0.isFinite }.max() ?? 0 + } + } + + struct ComposerTrailingControlKey: LayoutValueKey { + static let defaultValue = false + } + + /// Each action has exactly one SwiftUI identity, including picker/presentation + /// state. Width proposals wrap labels and rows without stateful layout feedback. + struct ComposerControlFlow: Layout { + var layoutDirection: LayoutDirection = .leftToRight + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let width = availableWidth(proposal.width, subviews: subviews) + let plan = ComposerFlowPlan( + sizes: sizes(width: width, subviews: subviews), width: width, + trailingIndices: trailingIndices(subviews)) + return CGSize(width: width, height: plan.height) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let plan = ComposerFlowPlan( + sizes: sizes(width: bounds.width, subviews: subviews), width: bounds.width, + trailingIndices: trailingIndices(subviews)) + for (index, frame) in plan.frames.enumerated() { + guard let frame else { continue } + let x = layoutDirection == .rightToLeft ? bounds.maxX - frame.minX : bounds.minX + frame.minX + subviews[index].place( + at: CGPoint(x: x, y: bounds.minY + frame.minY), + anchor: layoutDirection == .rightToLeft ? .topTrailing : .topLeading, + proposal: ProposedViewSize(frame.size)) + } + } + + private func trailingIndices(_ subviews: Subviews) -> Set { + Set(subviews.indices.filter { subviews[$0][ComposerTrailingControlKey.self] }) + } + + private func availableWidth(_ proposed: CGFloat?, subviews: Subviews) -> CGFloat { + if let proposed, proposed.isFinite { return max(44, proposed) } + let natural = subviews.map { $0.sizeThatFits(.unspecified) }.filter { $0.width > 0 && $0.height > 0 } + return max(44, natural.reduce(0) { $0 + max(44, $1.width) } + CGFloat(max(0, natural.count - 1)) * 8) + } + + private func sizes(width: CGFloat, subviews: Subviews) -> [CGSize] { + subviews.map { view in + let natural = view.sizeThatFits(.unspecified) + guard natural.width > 0, natural.height > 0 else { return .zero } + let proposed = min(max(44, width), max(44, natural.width)) + let measured = view.sizeThatFits(ProposedViewSize(width: proposed, height: nil)) + return CGSize(width: proposed, height: max(44, measured.height)) + } + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Composer/ComposerEditOrderingProbe.swift b/apps/maple-ios/Sources/MapleAppUI/Composer/ComposerEditOrderingProbe.swift new file mode 100644 index 000000000..478a9196b --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Composer/ComposerEditOrderingProbe.swift @@ -0,0 +1,2188 @@ +#if os(iOS) && DEBUG + import MapleAppCore + import SwiftUI + import UIKit + import UniformTypeIdentifiers + + @MainActor + enum ComposerEditOrderingProbe { + private static var failureDetails: [String] = [] + + private static func labeledCheck(_ result: Bool, label: String, check: (Bool) -> Void) { + check(result) + if !result { failureDetails.append(label) } + } + + // Same production Coordinator and native UITextView; each case controls + // callback ordering without a real delegate silently repairing the sequence. + static func run() async -> String { + let cases: [(String, ((Bool) -> Void) -> Void)] = [ + ( + "proposedEditSurvivesPreMutationSelectionAndPublishesExactlyOnce", + proposedEditSurvivesPreMutationSelectionAndPublishesExactlyOnce + ), + ( + "sameTextReplacementWaitsForCompletionThenCarriesExactIntent", + sameTextReplacementWaitsForCompletionThenCarriesExactIntent + ), + ( + "rejectedProposalDoesNotCreateAnEditOrPoisonTheNextKnownEdit", + rejectedProposalDoesNotCreateAnEditOrPoisonTheNextKnownEdit + ), + ("unmatchedActualMutationStillFailsClosed", unmatchedActualMutationStillFailsClosed), + ("mutationWithoutAProposalStillFailsClosed", mutationWithoutAProposalStillFailsClosed), + ( + "oldOwnerFlushRetainsExactEditAndRoutesItOnlyToOldOwner", + oldOwnerFlushRetainsExactEditAndRoutesItOnlyToOldOwner + ), + ("overflowingActualInputStillFailsClosed", overflowingActualInputStillFailsClosed), + ( + "ownedInputCompletionPublishesAfterNestedCallbacks", + ownedInputCompletionPublishesAfterNestedCallbacks + ), + ( + "authoritativeSameOwnerUpdateRetiresPendingProposal", + authoritativeSameOwnerUpdateRetiresPendingProposal + ), + ( + "resignationIncludesPriorInputBeforeSubmit", + resignationIncludesPriorInputBeforeSubmit + ), + ("resignationOrdersPriorInputBeforeShiftNewline", resignationOrdersPriorInputBeforeShiftNewline), + ( + "hardwareReturnAndKeypadCommandsUseSameGuardedPath", + hardwareReturnAndKeypadCommandsUseSameGuardedPath + ), + ( + "ordinaryTextInputPreservesNewlinesWithoutSubmitting", + ordinaryTextInputPreservesNewlinesWithoutSubmitting + ), + ( + "markdownNewlineRetainsExactDictationAnchorAndComposition", + markdownNewlineRetainsExactDictationAnchorAndComposition + ), + ("markedInputDoesNotExposeOrExecuteReturnCommands", markedInputDoesNotExposeOrExecuteReturnCommands), + ("retiredOwnerCannotExecuteCachedReturnCommand", retiredOwnerCannotExecuteCachedReturnCommand), + ( + "reentrantHardwareCommandCannotSubmitTwice", + reentrantHardwareCommandCannotSubmitTwice + ), + ("distinctShiftActionsEachInsertOneNewline", distinctShiftActionsEachInsertOneNewline), + ("refusedResignationDoesNotSendOrClear", refusedResignationDoesNotSendOrClear), + ("compositionDuringResignationCancelsWithoutCommit", compositionDuringResignationCancelsWithoutCommit), + ("revokedOwnerWithoutInstallationCannotSend", revokedOwnerWithoutInstallationCannotSend), + ("successorIsInstalledBeforeHardwareFocusResumes", successorIsInstalledBeforeHardwareFocusResumes), + ( + "replacementDuringResignationCancelsSend", + replacementDuringResignationCancelsSend + ), + ("nestedFocusLossDuringResignationCancelsSend", nestedFocusLossDuringResignationCancelsSend), + ("pasteDuringResignationCancelsSend", pasteDuringResignationCancelsSend), + ("publicationRevocationCancelsHardwareSend", publicationRevocationCancelsHardwareSend), + ( + "postReturnTextStaysWithAuthoritativeSuccessor", + postReturnTextStaysWithAuthoritativeSuccessor + ), + ( + "sameOwnerSubmitClearsBeforePendingNativeInsertion", + sameOwnerSubmitClearsBeforePendingNativeInsertion + ), + ("rotatedSubmitRoutesPendingInputOnlyToSuccessor", rotatedSubmitRoutesPendingInputOnlyToSuccessor), + ("rejectedAndReservedSubmitRetainCurrentDraft", rejectedAndReservedSubmitRetainCurrentDraft), + ("reservedOwnerAppliesLaterAuthoritativeSuccessor", reservedOwnerAppliesLaterAuthoritativeSuccessor), + ("submitRefreshCannotReplaceReentrantOwner", submitRefreshCannotReplaceReentrantOwner), + ("submitRefreshRejectsNilAndDifferentAccount", submitRefreshRejectsNilAndDifferentAccount), + ( + "postSubmitPreservesExactInputDuringNativeAssignment", + postSubmitPreservesExactInputDuringNativeAssignment + ), + ("postSubmitPreservesExactInputDuringFocusChange", postSubmitPreservesExactInputDuringFocusChange), + ( + "postSubmitReplacementRevokesUnpublishedNativeInput", + postSubmitReplacementRevokesUnpublishedNativeInput + ), + ( + "postSubmitUnknownNativeMutationKeepsMarkerFailure", + postSubmitUnknownNativeMutationKeepsMarkerFailure + ), + ("postSubmitNestedRenderWithoutInputStillUpdates", postSubmitNestedRenderWithoutInputStillUpdates), + ("postSubmitStaleOwnerRenderCannotReclaimSuccessor", postSubmitStaleOwnerRenderCannotReclaimSuccessor), + ("postSubmitEqualBaselineRuleEndsAtOuterUnwind", postSubmitEqualBaselineRuleEndsAtOuterUnwind), + ( + "ordinarySameOwnerInstallPreservesMarkedComposition", + ordinarySameOwnerInstallPreservesMarkedComposition + ), + ( + "hardwareSendDismissalPreservesSuccessorKeyboardInput", + hardwareSendDismissalPreservesSuccessorKeyboardInput + ), + ("keyboardPresentationPreservesSendDisposition", keyboardPresentationPreservesSendDisposition), + ( + "explicitEditingRestoresSystemKeyboardWithExactInput", + explicitEditingRestoresSystemKeyboardWithExactInput + ), + ( + "ordinaryOwnerAndTeardownResetKeyboardPresentation", + ordinaryOwnerAndTeardownResetKeyboardPresentation + ), + ( + "keyboardPresentationReentryCannotReclaimReplacement", + keyboardPresentationReentryCannotReclaimReplacement + ), + ( + "keyboardRestorationPreservesMarkedAndRevokedOwners", + keyboardRestorationPreservesMarkedAndRevokedOwners + ), + ("compactInteractionReclaimsRetainedKeyboardFocus", compactInteractionReclaimsRetainedKeyboardFocus), + ("compactInteractionSurvivesSameOwnerRender", compactInteractionSurvivesSameOwnerRender), + ( + "compactInteractionStartsUnfocusedEditorWithExactInput", + compactInteractionStartsUnfocusedEditorWithExactInput + ), + ( + "compactInteractionCannotReclaimRevokedPresentation", + compactInteractionCannotReclaimRevokedPresentation + ), + ( + "lateNativeFocusCallbacksCannotReopenOrDismissEditor", + lateNativeFocusCallbacksCannotReopenOrDismissEditor + ), + ( + "unrequestedNativeFocusCannotReopenDismissedComposer", + unrequestedNativeFocusCannotReopenDismissedComposer + ), + ("nativeTextInsetsKeepCaretAndPlaceholderAligned", nativeTextInsetsKeepCaretAndPlaceholderAligned), + ] + var lines = cases.map { name, exercise in + var failures = 0 + failureDetails.removeAll(keepingCapacity: true) + exercise { if !$0 { failures += 1 } } + let details = failureDetails.isEmpty ? "" : "; " + failureDetails.joined(separator: ",") + return "\(name): \(failures == 0 ? "passed" : "FAILED(\(failures)\(details))")" + } + var failures = 0 + failureDetails.removeAll(keepingCapacity: true) + await nativeImagePasteRoutesProvidersAndPreservesTextFallback { if !$0 { failures += 1 } } + let details = failureDetails.isEmpty ? "" : "; " + failureDetails.joined(separator: ",") + lines.append( + "nativeImagePasteRoutesProvidersAndPreservesTextFallback: \(failures == 0 ? "passed" : "FAILED(\(failures)\(details))")" + ) + return lines.joined(separator: "\n") + } + + private static func proposedEditSurvivesPreMutationSelectionAndPublishesExactlyOnce(_ check: (Bool) -> Void) { + let harness = Harness(text: "aaaa", marker: 2) + defer { harness.close() } + let range = NSRange(location: 0, length: 1) + check(harness.coordinator.textView(harness.view, shouldChangeTextIn: range, replacementText: "")) + harness.view.selectedRange = NSRange(location: 1, length: 0) + harness.coordinator.textViewDidChangeSelection(harness.view) + check(harness.capture.events.last?.0.selectionUTF16 == NSRange(location: 1, length: 0)) + check(harness.capture.edits.isEmpty) + check(harness.capture.marker?.offsetUTF16 == 2) + check(harness.coordinator.requests.count == 1) + + harness.view.text = "aaa" + harness.view.selectedRange = NSRange(location: 0, length: 0) + harness.coordinator.textViewDidChange(harness.view) + check(harness.capture.edits == [DictationTextEdit(beforeText: "aaaa", rangeUTF16: range, replacement: "")]) + check(harness.capture.marker?.offsetUTF16 == 1) + check(harness.capture.text == "aaa") + check(harness.coordinator.requests.isEmpty) + harness.coordinator.textViewDidChangeSelection(harness.view) + harness.coordinator.publish() + check(harness.capture.edits.count == 1) + } + + private static func sameTextReplacementWaitsForCompletionThenCarriesExactIntent(_ check: (Bool) -> Void) { + let harness = Harness(text: "aaaa", marker: 0) + defer { harness.close() } + let range = NSRange(location: 0, length: 1) + _ = harness.coordinator.textView(harness.view, shouldChangeTextIn: range, replacementText: "a") + harness.view.selectedRange = NSRange(location: 1, length: 0) + harness.coordinator.textViewDidChangeSelection(harness.view) + check(harness.capture.edits.isEmpty) + check(harness.capture.marker?.offsetUTF16 == 0) + harness.coordinator.textViewDidChange(harness.view) + check(harness.capture.edits == [DictationTextEdit(beforeText: "aaaa", rangeUTF16: range, replacement: "a")]) + check(harness.capture.marker?.offsetUTF16 == 1) + harness.coordinator.publish(completedInput: true) + check(harness.capture.edits.count == 1) + } + + private static func rejectedProposalDoesNotCreateAnEditOrPoisonTheNextKnownEdit(_ check: (Bool) -> Void) { + let harness = Harness(text: "aaaa", marker: 2) + defer { harness.close() } + _ = harness.coordinator.textView( + harness.view, shouldChangeTextIn: NSRange(location: 0, length: 1), replacementText: "x") + harness.view.selectedRange = NSRange(location: 1, length: 0) + harness.coordinator.textViewDidChangeSelection(harness.view) + harness.coordinator.publish(completedInput: true) + check(harness.capture.edits.isEmpty) + check(harness.capture.marker?.offsetUTF16 == 2) + check(harness.coordinator.requests.isEmpty) + + let range = NSRange(location: 3, length: 1) + _ = harness.coordinator.textView(harness.view, shouldChangeTextIn: range, replacementText: "z") + harness.view.text = "aaaz" + harness.coordinator.textViewDidChange(harness.view) + check(harness.capture.edits == [DictationTextEdit(beforeText: "aaaa", rangeUTF16: range, replacement: "z")]) + check(harness.capture.marker?.offsetUTF16 == 2) + } + + private static func unmatchedActualMutationStillFailsClosed(_ check: (Bool) -> Void) { + let harness = Harness(text: "aaaa", marker: 2) + defer { harness.close() } + _ = harness.coordinator.textView( + harness.view, shouldChangeTextIn: NSRange(location: 0, length: 1), replacementText: "x") + harness.view.selectedRange = NSRange(location: 1, length: 0) + harness.coordinator.textViewDidChangeSelection(harness.view) + harness.view.text = "azaa" + harness.coordinator.textViewDidChange(harness.view) + check(harness.capture.edits.count == 1) + check(harness.capture.edits.first?.rangeUTF16.location == NSNotFound) + check(harness.capture.edits.first?.resultingText == nil) + check(harness.capture.rebaseFailures == 1) + } + + private static func mutationWithoutAProposalStillFailsClosed(_ check: (Bool) -> Void) { + let harness = Harness(text: "aaaa", marker: 2) + defer { harness.close() } + harness.view.text = "aaa" + harness.coordinator.publish() + check(harness.capture.edits.count == 1) + check(harness.capture.edits.first?.resultingText == nil) + check(harness.capture.rebaseFailures == 1) + } + + private static func oldOwnerFlushRetainsExactEditAndRoutesItOnlyToOldOwner(_ check: (Bool) -> Void) { + let harness = Harness(text: "aaaa", marker: 2) + defer { harness.close() } + let range = NSRange(location: 0, length: 1) + _ = harness.coordinator.textView(harness.view, shouldChangeTextIn: range, replacementText: "") + harness.view.selectedRange = NSRange(location: 1, length: 0) + harness.coordinator.textViewDidChangeSelection(harness.view) + harness.view.text = "aaa" + var nextEvents: [DictationEditorState] = [] + let next = ComposerEditor( + text: .constant("next owner"), focused: .constant(false), ownerID: UUID(), + placeholder: "Synthetic next owner", onChange: { state, _ in nextEvents.append(state) }, onSubmit: {}) + harness.coordinator.install(next, in: harness.view) + check(harness.capture.text == "aaa") + check(harness.capture.edits == [DictationTextEdit(beforeText: "aaaa", rangeUTF16: range, replacement: "")]) + check(nextEvents.isEmpty) + check(harness.view.text == "next owner") + check(harness.coordinator.requests.isEmpty) + } + + private static func overflowingActualInputStillFailsClosed(_ check: (Bool) -> Void) { + let harness = Harness(text: "aaaa", marker: 2) + defer { harness.close() } + for index in 0..<65 { + harness.coordinator.recordReplacement( + before: "aaaa", range: NSRange(location: 0, length: 1), replacement: String(index)) + } + harness.view.selectedRange = NSRange(location: 1, length: 0) + harness.coordinator.textViewDidChangeSelection(harness.view) + check(harness.capture.edits.isEmpty) + check(harness.coordinator.requestOverflow) + harness.view.text = "unknown" + harness.coordinator.textViewDidChange(harness.view) + check(harness.capture.edits.count == 1) + check(harness.capture.edits.first?.resultingText == nil) + check(harness.capture.rebaseFailures == 1) + } + + private static func ownedInputCompletionPublishesAfterNestedCallbacks(_ check: (Bool) -> Void) { + let harness = Harness(text: "aaaa", marker: 2) + defer { harness.close() } + let coordinator = harness.coordinator + harness.view.delegate = coordinator + harness.view.pasteDelegate = coordinator + harness.view.willReplace = { [weak coordinator] before, range, replacement in + coordinator?.recordNativeReplacement(before: before, range: range, replacement: replacement) + } + harness.view.changed = { [weak coordinator] in coordinator?.publish(completedInput: true) } + harness.view.selectedRange = NSRange(location: 0, length: 0) + harness.view.insertText("b") + check(!harness.view.performingInputOperation) + check(harness.capture.text == "baaaa") + check( + harness.capture.edits == [ + DictationTextEdit(beforeText: "aaaa", rangeUTF16: NSRange(location: 0, length: 0), replacement: "b") + ]) + check(harness.capture.marker?.offsetUTF16 == 3) + check(harness.capture.rebaseFailures == 0) + } + + private static func authoritativeSameOwnerUpdateRetiresPendingProposal(_ check: (Bool) -> Void) { + let harness = Harness(text: "aaaa", marker: 2) + defer { harness.close() } + var inserted = false + harness.capture.afterReceive = { state, edits in + guard !inserted, state.text == "aaaa", state.selectionUTF16.location == 1, edits.isEmpty else { return } + inserted = true + // The real dictation path may insert a waiting result when a marked + // range clears. Recreate only its same-owner authoritative update. + harness.capture.text = "aaaa transcript" + harness.coordinator.install(harness.coordinator.parent, in: harness.view) + } + _ = harness.coordinator.textView( + harness.view, shouldChangeTextIn: NSRange(location: 0, length: 1), replacementText: "") + harness.view.selectedRange = NSRange(location: 1, length: 0) + harness.coordinator.textViewDidChangeSelection(harness.view) + check(inserted) + check(harness.view.text == "aaaa transcript") + check(harness.coordinator.reportedText == "aaaa transcript") + check(harness.coordinator.requests.isEmpty) + check(harness.capture.edits.isEmpty) + + // A later mutation from the retired proposal must not be reinterpreted + // as a valid deletion in the authoritative replacement text. + harness.view.text = "aaa" + harness.coordinator.textViewDidChange(harness.view) + check(harness.capture.edits.count == 1) + check(harness.capture.edits.first?.beforeText == "aaaa transcript") + check(harness.capture.edits.first?.resultingText == nil) + } + + private static func returnCommand( + _ view: ComposerTextView, modifiers: UIKeyModifierFlags = [], input: String = "\r" + ) -> UIKeyCommand? { + view.keyCommands?.first { + $0.action == #selector(ComposerTextView.handleComposerReturn(_:)) && $0.modifierFlags == modifiers + && $0.input == input + } + } + + private static func wireNativeInput(_ harness: Harness) { + let coordinator = harness.coordinator + harness.view.delegate = coordinator + harness.view.pasteDelegate = coordinator + harness.view.willReplace = { [weak coordinator] before, range, replacement in + coordinator?.recordNativeReplacement(before: before, range: range, replacement: replacement) + } + harness.view.changed = { [weak coordinator] in coordinator?.publish(completedInput: true) } + harness.view.submit = { [weak coordinator] in coordinator?.submit() } + } + + // These cases exercise command ownership and text semantics. Only an + // actual hardware burst can prove UIKit's command/text delivery ordering. + private static func resignationIncludesPriorInputBeforeSubmit(_ check: (Bool) -> Void) { + let harness = Harness(text: "ab", marker: 2, focus: true) + defer { harness.close() } + wireNativeInput(harness) + guard let command = returnCommand(harness.view), let action = command.action else { check(false); return } + harness.coordinator.installing = true + check(!harness.view.canPerformAction(action, withSender: command)) + _ = harness.view.perform(action, with: command) + check(harness.capture.submitted.isEmpty) + harness.coordinator.installing = false + var barriers = 0 + var unsentAtBarrier = false + harness.view.beforeResignFirstResponderForProbe = { + harness.view.beforeResignFirstResponderForProbe = nil + barriers += 1 + unsentAtBarrier = harness.capture.submitted.isEmpty + harness.view.insertText("c") + } + _ = harness.view.perform(action, with: command) + check(unsentAtBarrier) + check(barriers == 1 && harness.capture.submitted == ["abc"]) + check(harness.capture.text == "abc" && harness.view.isFirstResponder) + check(harness.capture.edits.last?.replacement == "c") + check(harness.capture.rebaseFailures == 0) + ordinaryRenderInsideResignation(check) + } + + private static func ordinaryRenderInsideResignation(_ check: (Bool) -> Void) { + for variant in [ + "ordinary", "pending", "unfocused", "cancelled", "invalid-proposal", "overflow", "selection", + "authority", + ] { + let harness = Harness(text: "ab", marker: 2, focus: true) + defer { harness.close() } + wireNativeInput(harness) + let original = harness.coordinator.parent + var staleSnapshot = false + var incomingCurrent = true + var revokeIncomingOnOldAdmission = false + var obsoleteSubmits = 0 + let oldProjection = ComposerEditor( + text: Binding( + get: { staleSnapshot ? "" : harness.capture.text }, + set: { harness.capture.text = $0 }), + focused: original.$focused, ownerID: original.ownerID, + accountGeneration: original.accountGeneration, + isCurrentOwner: { + if revokeIncomingOnOldAdmission { incomingCurrent = false } + return true + }, + placeholder: "Old projection", + onChange: original.onChange, onSubmit: { obsoleteSubmits += 1 }) + harness.coordinator.install(oldProjection, in: harness.view) + guard let command = returnCommand(harness.view), let action = command.action else { + labeledCheck(false, label: "refresh.\(variant).command", check: check); continue + } + var renders = 0 + var proposalRetained = false + var requestsAfterRefresh = 0 + let fresh = ComposerEditor( + text: Binding(get: { harness.capture.text }, set: { harness.capture.text = $0 }), + focused: variant == "unfocused" ? .constant(false) : original.$focused, + ownerID: original.ownerID, accountGeneration: original.accountGeneration, + isCurrentOwner: { incomingCurrent }, + placeholder: "Current projection", accessibilityIdentifier: "composer.current-projection", + externalSelection: variant == "selection" + ? DictationEditorState( + text: "abc", selectionUTF16: NSRange(location: 0, length: 0), markedRangeUTF16: nil) : nil, + onChange: original.onChange, + onSubmit: { + harness.capture.submitted.append(harness.capture.text) + harness.capture.text = ""; harness.capture.focused = false + harness.capture.marker = DictationInsertionAnchor(text: "", offsetUTF16: 0) + }) + harness.view.beforeResignFirstResponderForProbe = { + harness.view.beforeResignFirstResponderForProbe = nil + // Real native replacement/publication, then the reentrant + // SwiftUI projection observed before outer resignation ends. + harness.view.insertText("c") + staleSnapshot = true + if variant == "pending" { + _ = harness.coordinator.textView( + harness.view, shouldChangeTextIn: NSRange(location: 3, length: 0), replacementText: "d") + } else if variant == "invalid-proposal" { + harness.coordinator.recordReplacement( + before: "abc", range: NSRange(location: 30, length: 0), replacement: "d") + } else if variant == "overflow" { + for index in 0..<65 { + harness.coordinator.recordReplacement( + before: "abc", range: NSRange(location: 3, length: 0), replacement: String(index)) + } + } else if variant == "cancelled" { + harness.view.cancelHardwareReturn() + } + revokeIncomingOnOldAdmission = variant == "authority" + let priorRequests = harness.coordinator.requests + renders += 1 + harness.coordinator.install(fresh, in: harness.view) + requestsAfterRefresh = harness.coordinator.requests.count + proposalRetained = harness.coordinator.requests == priorRequests + } + _ = harness.view.perform(action, with: command) + labeledCheck(renders == 1, label: "refresh.\(variant).oneRender", check: check) + labeledCheck(obsoleteSubmits == 0, label: "refresh.\(variant).currentCallback", check: check) + if variant == "ordinary" || variant == "pending" { + labeledCheck( + harness.capture.submitted == ["abc"], label: "refresh.\(variant).exactSubmit", check: check) + labeledCheck( + harness.capture.text.isEmpty && harness.view.text.isEmpty, + label: "refresh.\(variant).emptySuccessor", check: check) + labeledCheck( + harness.view.accessibilityIdentifier == "composer.current-projection" + && harness.view.placeholderLabel.text == "Current projection", + label: "refresh.\(variant).metadata", check: check) + labeledCheck(proposalRetained, label: "refresh.\(variant).proposalUntouched", check: check) + labeledCheck( + requestsAfterRefresh == (variant == "pending" ? 1 : 0), + label: "refresh.\(variant).requestCount", check: check) + if variant == "pending" { + // Actual reception follows the installed empty successor; + // never infer that an earlier delegate proposal was applied. + harness.view.insertText("d") + labeledCheck( + harness.capture.text == "d" && harness.view.text == "d", + label: "refresh.pending.successorInput", check: check) + labeledCheck( + harness.capture.edits.last + == DictationTextEdit( + beforeText: "", rangeUTF16: NSRange(location: 0, length: 0), replacement: "d"), + label: "refresh.pending.exactSuccessorEdit", check: check) + } + labeledCheck( + harness.capture.rebaseFailures == 0, label: "refresh.\(variant).exactEdits", check: check) + } else { + labeledCheck( + harness.capture.submitted.isEmpty, label: "refresh.\(variant).revoked", check: check) + labeledCheck( + harness.capture.text == "abc" && harness.view.text == "abc", + label: "refresh.\(variant).textRetained", check: check) + } + } + } + + private static func resignationOrdersPriorInputBeforeShiftNewline(_ check: (Bool) -> Void) { + let harness = Harness(text: "ab", marker: 2, focus: true) + defer { harness.close() } + wireNativeInput(harness) + guard let command = returnCommand(harness.view, modifiers: .shift), let action = command.action else { + check(false); return + } + harness.view.beforeResignFirstResponderForProbe = { + harness.view.beforeResignFirstResponderForProbe = nil + harness.view.insertText("c") + } + _ = harness.view.perform(action, with: command) + check(harness.capture.text == "abc\n") + check(harness.capture.submitted.isEmpty && harness.view.isFirstResponder) + check(harness.capture.edits.map(\.replacement) == ["c", "\n"]) + check(harness.capture.rebaseFailures == 0) + } + + private static func ordinaryTextInputPreservesNewlinesWithoutSubmitting(_ check: (Bool) -> Void) { + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + wireNativeInput(harness) + _ = returnCommand(harness.view) + harness.view.insertText("\n") + harness.view.insertText("left\nright") + check(harness.capture.text == "abc\nleft\nright") + check(harness.capture.submitted.isEmpty) + check(harness.capture.edits.map(\.replacement) == ["\n", "left\nright"]) + check(harness.capture.rebaseFailures == 0) + } + + private static func markdownNewlineRetainsExactDictationAnchorAndComposition(_ check: (Bool) -> Void) { + for delegatePath in [false, true] { + let text = "- 🐝", caret = text.utf16.count + let harness = Harness(text: text, marker: caret, focus: true) + defer { harness.close() } + wireNativeInput(harness) + harness.view.selectedRange = NSRange(location: caret, length: 0) + if delegatePath { + check( + !harness.coordinator.textView( + harness.view, shouldChangeTextIn: harness.view.selectedRange, replacementText: "\n")) + } else { + harness.view.insertText("\n") + } + check(harness.capture.text == text + "\n- ") + check(harness.view.selectedRange == NSRange(location: caret + 3, length: 0)) + check( + harness.capture.edits == [ + DictationTextEdit( + beforeText: text, rangeUTF16: NSRange(location: caret, length: 0), replacement: "\n- ") + ]) + check(harness.capture.marker?.offsetUTF16 == caret + 3 && harness.capture.rebaseFailures == 0) + check(harness.capture.submitted.isEmpty) + } + let composing = Harness(text: "- item", marker: 6, focus: true) + defer { composing.close() } + wireNativeInput(composing) + composing.view.selectedRange = NSRange(location: 6, length: 0) + composing.view.setMarkedText("pending", selectedRange: NSRange(location: 7, length: 0)) + let before = composing.view.text + check(composing.view.markedTextRange != nil) + check( + composing.coordinator.textView( + composing.view, shouldChangeTextIn: composing.view.selectedRange, replacementText: "\n")) + check(composing.view.text == before && composing.view.markedTextRange != nil) + check(composing.capture.submitted.isEmpty) + } + + private static func hardwareReturnAndKeypadCommandsUseSameGuardedPath(_ check: (Bool) -> Void) { + // Controlled command matching only; actual keypad HID delivery is a separate native check. + for input in ["\r", "\u{3}"] { + for modifiers in [UIKeyModifierFlags(), .shift] { + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + wireNativeInput(harness) + let commands = + harness.view.keyCommands?.filter { + $0.action == #selector(ComposerTextView.handleComposerReturn(_:)) + } ?? [] + check(commands.count == 4) + check(Set(commands.compactMap(\.input)) == Set(["\r", "\u{3}"])) + guard let command = returnCommand(harness.view, modifiers: modifiers, input: input), + let action = command.action + else { check(false); continue } + check(harness.view.canPerformAction(action, withSender: command)) + let unsupported = UIKeyCommand( + title: "", action: action, input: "\n", modifierFlags: modifiers, + propertyList: command.propertyList) + check(!harness.view.canPerformAction(action, withSender: unsupported)) + _ = harness.view.perform(action, with: unsupported) + check(harness.capture.text == "abc" && harness.capture.submitted.isEmpty) + _ = harness.view.perform(action, with: command) + + if modifiers == .shift { + check(harness.capture.text == "abc\n" && harness.capture.submitted.isEmpty) + check( + harness.capture.edits == [ + DictationTextEdit( + beforeText: "abc", rangeUTF16: NSRange(location: 3, length: 0), replacement: "\n") + ]) + } else { + check(harness.capture.text == "abc" && harness.capture.submitted == ["abc"]) + check(harness.capture.edits.isEmpty) + } + check(harness.capture.rebaseFailures == 0) + } + } + } + + private static func markedInputDoesNotExposeOrExecuteReturnCommands(_ check: (Bool) -> Void) { + let harness = Harness(text: "", marker: 0, focus: true) + defer { harness.close() } + wireNativeInput(harness) + guard let saved = returnCommand(harness.view), let savedAction = saved.action else { check(false); return } + harness.view.setMarkedText("にほん", selectedRange: NSRange(location: 3, length: 0)) + check(harness.view.markedTextRange != nil) + check(returnCommand(harness.view) == nil) + check(!harness.view.canPerformAction(savedAction, withSender: saved)) + _ = harness.view.perform(savedAction, with: saved) + + check(harness.capture.submitted.isEmpty) + check(harness.view.markedTextRange != nil) + check(harness.view.text == "にほん") + harness.view.unmarkText() + check(returnCommand(harness.view) != nil) + check(harness.capture.submitted.isEmpty) + } + + private static func retiredOwnerCannotExecuteCachedReturnCommand(_ check: (Bool) -> Void) { + let harness = Harness(text: "old draft", marker: 0, focus: true) + defer { harness.close() } + wireNativeInput(harness) + guard let old = returnCommand(harness.view), let oldAction = old.action else { check(false); return } + var nextSends = 0 + let next = ComposerEditor( + text: .constant("replacement"), focused: .constant(true), ownerID: UUID(), + accountGeneration: UUID(), placeholder: "Synthetic replacement", + onSubmit: { nextSends += 1 }) + harness.coordinator.install(next, in: harness.view) + + check(!harness.view.canPerformAction(oldAction, withSender: old)) + _ = harness.view.perform(oldAction, with: old) + check(nextSends == 0 && harness.capture.submitted.isEmpty) + guard let current = returnCommand(harness.view), let currentAction = current.action else { + check(false); return + } + check((current.propertyList as? String) != (old.propertyList as? String)) + check(harness.view.canPerformAction(currentAction, withSender: current)) + _ = harness.view.perform(currentAction, with: current) + + check(nextSends == 1) + ComposerEditor.dismantleUIView(harness.view, coordinator: harness.coordinator) + + check(!harness.view.canPerformAction(currentAction, withSender: current)) + _ = harness.view.perform(currentAction, with: current) + check(nextSends == 1) + } + + private static func reentrantHardwareCommandCannotSubmitTwice(_ check: (Bool) -> Void) { + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + wireNativeInput(harness) + guard let command = returnCommand(harness.view), let action = command.action else { check(false); return } + var reentryRejected = false + harness.view.beforeResignFirstResponderForProbe = { + harness.view.beforeResignFirstResponderForProbe = nil + reentryRejected = !harness.view.canPerformAction(action, withSender: command) + _ = harness.view.perform(action, with: command) + } + _ = harness.view.perform(action, with: command) + check(reentryRejected) + check(harness.capture.submitted == ["abc"] && harness.view.isFirstResponder) + } + + private static func distinctShiftActionsEachInsertOneNewline(_ check: (Bool) -> Void) { + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + wireNativeInput(harness) + guard let command = returnCommand(harness.view, modifiers: .shift), let action = command.action else { + check(false); return + } + _ = harness.view.perform(action, with: command) + check(harness.capture.text == "abc\n") + _ = harness.view.perform(action, with: command) + check(harness.capture.text == "abc\n\n" && harness.capture.submitted.isEmpty) + check(harness.capture.edits.map(\.replacement) == ["\n", "\n"]) + check(harness.capture.rebaseFailures == 0) + } + + private static func replacementDuringResignationCancelsSend(_ check: (Bool) -> Void) { + for sameOwner in [false, true] { + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + wireNativeInput(harness) + guard let command = returnCommand(harness.view), let action = command.action else { + check(false); continue + } + var replacementSends = 0 + let replacement = ComposerEditor( + text: .constant("authoritative replacement"), focused: .constant(false), + ownerID: sameOwner ? harness.coordinator.parent.ownerID : UUID(), + accountGeneration: harness.coordinator.parent.accountGeneration, + placeholder: "Replacement", onSubmit: { replacementSends += 1 }) + harness.view.beforeResignFirstResponderForProbe = { + harness.view.beforeResignFirstResponderForProbe = nil + harness.coordinator.install(replacement, in: harness.view) + } + _ = harness.view.perform(action, with: command) + check(harness.view.text == "authoritative replacement") + check(harness.capture.submitted.isEmpty && replacementSends == 0) + check(!harness.view.isFirstResponder) + } + } + + private static func nestedFocusLossDuringResignationCancelsSend(_ check: (Bool) -> Void) { + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + wireNativeInput(harness) + guard let command = returnCommand(harness.view), let action = command.action else { check(false); return } + var resigned = false + var resumed = false + harness.view.beforeResignFirstResponderForProbe = { + harness.view.beforeResignFirstResponderForProbe = nil + resigned = harness.view.resignFirstResponder() + resumed = harness.view.becomeFirstResponder() + } + _ = harness.view.perform(action, with: command) + check(resigned) + check(resumed) + check(harness.capture.submitted.isEmpty) + _ = harness.view.perform(action, with: command) + check(harness.capture.submitted == ["abc"]) + } + + private static func pasteDuringResignationCancelsSend(_ check: (Bool) -> Void) { + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + wireNativeInput(harness) + guard let command = returnCommand(harness.view), let action = command.action else { check(false); return } + let previous = UIPasteboard.general.items + defer { UIPasteboard.general.setItems(previous, options: [.localOnly: true]) } + UIPasteboard.general.items = [] + harness.view.beforeResignFirstResponderForProbe = { + harness.view.beforeResignFirstResponderForProbe = nil + harness.view.paste(nil) + } + _ = harness.view.perform(action, with: command) + check(harness.capture.text == "abc" && harness.capture.submitted.isEmpty) + } + + private static func nativeImagePasteRoutesProvidersAndPreservesTextFallback(_ check: (Bool) -> Void) async { + let board = UIPasteboard.general, previous = board.items + defer { board.setItems(previous, options: [.localOnly: true]) } + let text = "- 🐝", caret = text.utf16.count + let harness = Harness(text: text, marker: caret, focus: true) + defer { harness.close() } + wireNativeInput(harness) + harness.view.selectedRange = NSRange(location: caret, length: 0) + var callbacks = 0, expectedProvider = false + harness.coordinator.parent.onPasteImages = { providers in + callbacks += 1 + expectedProvider = + providers.count == 1 && providers[0].hasItemConformingToTypeIdentifier(UTType.png.identifier) + return true + } + let image = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1)).pngData { context in + context.cgContext.setFillColor(UIColor.black.cgColor) + context.cgContext.fill(CGRect(x: 0, y: 0, width: 1, height: 1)) + } + board.setItems([[UTType.png.identifier: image]], options: [.localOnly: true]) + labeledCheck( + harness.view.canPerformAction(#selector(ComposerTextView.paste(_:)), withSender: nil), + label: "paste.image.action", check: check) + harness.view.paste(nil) + labeledCheck( + callbacks == 1 && expectedProvider && harness.capture.text == text && harness.capture.edits.isEmpty, + label: "paste.image.route[callbacks=\(callbacks);expectedType=\(expectedProvider)]", check: check) + board.setItems([[UTType.utf8PlainText.identifier: Data("\n".utf8)]], options: [.localOnly: true]) + harness.view.paste(nil) + // UIKit may finish text-provider decoding after paste(_:) returns. + // Keep this exact view and pasteboard alive until the Coordinator + // publishes an actual edit; the bound is only a test failure deadline. + let clock = ContinuousClock(), deadline = clock.now.advanced(by: .seconds(2)) + while harness.capture.edits.isEmpty, clock.now < deadline, !Task.isCancelled { + do { try await Task.sleep(for: .milliseconds(10)) } catch { break } + } + labeledCheck( + callbacks == 1 && harness.capture.text == text + "\n", + label: + "paste.text.literal[length=\(harness.capture.text.utf16.count);continued=\(harness.capture.text.hasSuffix("\n- "))]", + check: check) + labeledCheck( + harness.capture.edits == [ + DictationTextEdit( + beforeText: text, rangeUTF16: NSRange(location: caret, length: 0), replacement: "\n") + ], label: "paste.text.exact[edits=\(harness.capture.edits.count)]", check: check) + labeledCheck( + harness.capture.marker?.offsetUTF16 == caret + 1 && harness.capture.rebaseFailures == 0, + label: + "paste.text.anchor[offset=\(harness.capture.marker?.offsetUTF16 ?? -1);failures=\(harness.capture.rebaseFailures)]", + check: check) + labeledCheck( + harness.view.selectedRange == NSRange(location: caret + 1, length: 0), + label: "paste.text.caret", check: check) + labeledCheck(harness.capture.submitted.isEmpty, label: "paste.noSubmit", check: check) + + // Queue editing declines image import, but must keep a mixed + // clipboard's text. A consumed image/upgrade action keeps precedence. + for route in ["declined", "handled", "replacement", "reinstalled", "retired", "composition", "occluded"] { + let candidate = Harness(text: "old", marker: 3, focus: true) + defer { candidate.close() } + wireNativeInput(candidate) + candidate.view.selectedRange = NSRange(location: 3, length: 0) + let original = candidate.coordinator.parent, next = Capture(text: "Successor", marker: 9) + let pasted = "\n* pasted 🐝" + var callbacks = 0, expectedProvider = false, expectedText = "old" + candidate.coordinator.parent.onPasteImages = { providers in + callbacks += 1 + expectedProvider = providers.count == 1 + if route == "replacement" || route == "reinstalled" { + let successor = ComposerEditor( + text: Binding(get: { next.text }, set: { next.text = $0 }), + focused: .constant(true), ownerID: UUID(), accountGeneration: UUID(), + placeholder: "Synthetic successor", onChange: { next.receive($0, $1) }, onSubmit: {}) + candidate.coordinator.install(successor, in: candidate.view) + if route == "reinstalled" { candidate.coordinator.install(original, in: candidate.view) } + } else if route == "retired" { + candidate.coordinator.valid = false + } else if route == "composition" { + candidate.view.setMarkedText("IME", selectedRange: NSRange(location: 3, length: 0)) + } else if route == "occluded" { + candidate.coordinator.parent.isCurrentPresentation = { false } + } + expectedText = candidate.view.text ?? "" + return route == "handled" + } + board.setItems( + [[UTType.png.identifier: image, UTType.utf8PlainText.identifier: Data(pasted.utf8)]], + options: [.localOnly: true]) + candidate.view.paste(nil) + if route != "declined" { + labeledCheck( + candidate.coordinator.pendingTextPasteReads == 0, + label: "paste.mixed.\(route).noFallbackRead", check: check) + } + let routeDeadline = clock.now.advanced(by: .seconds(2)) + while clock.now < routeDeadline, !Task.isCancelled, + candidate.coordinator.pendingTextPasteReads > 0 + || (route == "declined" && candidate.capture.edits.isEmpty) + { + do { try await Task.sleep(for: .milliseconds(10)) } catch { break } + } + labeledCheck(expectedProvider, label: "paste.mixed.\(route).provider", check: check) + labeledCheck(callbacks == 1, label: "paste.mixed.\(route).routedOnce", check: check) + if route == "declined" { + labeledCheck( + candidate.capture.text == "old" + pasted + && candidate.capture.edits == [ + DictationTextEdit( + beforeText: "old", rangeUTF16: NSRange(location: 3, length: 0), replacement: pasted) + ], label: "paste.mixed.declined.exactText", check: check) + labeledCheck( + candidate.capture.marker?.offsetUTF16 == 3 + pasted.utf16.count + && candidate.view.selectedRange == NSRange(location: 3 + pasted.utf16.count, length: 0) + && candidate.capture.rebaseFailures == 0, + label: "paste.mixed.declined.caretAndAnchor", check: check) + } else { + labeledCheck( + candidate.view.text == expectedText && next.text == "Successor" && next.edits.isEmpty, + label: "paste.mixed.\(route).ownerUnchanged", check: check) + if route == "composition" { + labeledCheck( + candidate.view.markedTextRange != nil, + label: "paste.mixed.composition.retained", check: check) + } + } + labeledCheck(candidate.capture.submitted.isEmpty, label: "paste.mixed.\(route).noSubmit", check: check) + } + + for boundary in ["replacement", "reinstalled", "retired", "composition"] { + let candidate = Harness(text: "old", marker: 3, focus: true) + defer { candidate.close() } + wireNativeInput(candidate) + candidate.view.selectedRange = NSRange(location: 3, length: 0) + board.setItems([[UTType.utf8PlainText.identifier: Data("late".utf8)]], options: [.localOnly: true]) + candidate.view.paste(nil) + labeledCheck( + candidate.coordinator.pendingTextPasteReads > 0, + label: "paste.\(boundary).initiatingOwnerCaptured", check: check) + let original = candidate.coordinator.parent, next = Capture(text: "Successor", marker: 9) + if boundary == "replacement" || boundary == "reinstalled" { + let successor = ComposerEditor( + text: Binding(get: { next.text }, set: { next.text = $0 }), + focused: .constant(true), ownerID: UUID(), accountGeneration: UUID(), + placeholder: "Synthetic successor", onChange: { next.receive($0, $1) }, onSubmit: {}) + candidate.coordinator.install(successor, in: candidate.view) + if boundary == "reinstalled" { candidate.coordinator.install(original, in: candidate.view) } + } else if boundary == "retired" { + candidate.coordinator.valid = false + } else { + candidate.view.setMarkedText("IME", selectedRange: NSRange(location: 3, length: 0)) + } + let expectedText = candidate.view.text + let ownerDeadline = clock.now.advanced(by: .seconds(2)) + while candidate.coordinator.pendingTextPasteReads > 0, clock.now < ownerDeadline, + !Task.isCancelled + { + do { try await Task.sleep(for: .milliseconds(10)) } catch { break } + } + labeledCheck( + candidate.coordinator.pendingTextPasteReads == 0 && candidate.view.text == expectedText, + label: "paste.\(boundary).lateReadRejected", check: check) + labeledCheck( + next.text == "Successor" && next.edits.isEmpty, label: "paste.\(boundary).successorUnchanged", + check: check) + if boundary == "composition" { + labeledCheck( + candidate.view.markedTextRange != nil, label: "paste.composition.retained", check: check) + } + guard let start = candidate.view.position(from: candidate.view.beginningOfDocument, offset: 0), + let end = candidate.view.position(from: start, offset: 1), + let selected = candidate.view.textRange(from: start, to: end) + else { check(false); continue } + let previousSelection = candidate.view.selectedRange + let returnedSelection = candidate.coordinator.textPasteConfigurationSupporting( + candidate.view, performPasteOf: NSAttributedString(string: ""), to: selected) + labeledCheck( + candidate.view.text == expectedText, label: "paste.\(boundary).emptyResultIgnored", check: check) + labeledCheck( + candidate.view.selectedRange == previousSelection + && candidate.view.offset(from: candidate.view.beginningOfDocument, to: returnedSelection.start) + == previousSelection.location + && candidate.view.offset(from: returnedSelection.start, to: returnedSelection.end) + == previousSelection.length, + label: "paste.\(boundary).selectionRetained", check: check) + } + } + + private static func publicationRevocationCancelsHardwareSend(_ check: (Bool) -> Void) { + for revocation in [ + "focus", "composition", "replacement", "replacement-input", "replacement-unfocused", + "replacement-account", "replacement-cancel", + ] { + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + wireNativeInput(harness) + guard let command = returnCommand(harness.view), let action = command.action else { + labeledCheck(false, label: "\(revocation).command", check: check); continue + } + // A known native mutation waits for the submit-time publish. Its + // callback revokes the action without changing the draft owner. + labeledCheck( + harness.coordinator.textView( + harness.view, shouldChangeTextIn: NSRange(location: 3, length: 0), replacementText: "d"), + label: "\(revocation).proposal", check: check) + harness.view.delegate = nil + harness.view.text = "abcd" + harness.view.selectedRange = NSRange(location: 4, length: 0) + harness.view.delegate = harness.coordinator + var focusInputCount = 0 + var canceledDuringAdmission = false + var baselineBeforeFocus = false + let priorGeneration = harness.coordinator.parent.accountGeneration + harness.capture.afterReceive = { _, _ in + harness.capture.afterReceive = nil + if revocation == "composition" { + harness.view.setMarkedText("に", selectedRange: NSRange(location: 1, length: 0)) + } else if revocation.hasPrefix("replacement") { + harness.capture.text = "authoritative replacement" + var replacement = harness.coordinator.parent + if revocation == "replacement-unfocused" { harness.capture.focused = false } + if revocation == "replacement-account" { replacement.accountGeneration = UUID() } + if revocation == "replacement-input" { + harness.capture.marker = DictationInsertionAnchor( + text: harness.capture.text, offsetUTF16: harness.capture.text.utf16.count) + harness.view.beforeBecomeFirstResponderForProbe = { + harness.view.beforeBecomeFirstResponderForProbe = nil + focusInputCount += 1 + baselineBeforeFocus = + harness.coordinator.reportedText == "authoritative replacement" + && harness.view.text == "authoritative replacement" + && harness.coordinator.owner?.draft == harness.coordinator.parent.ownerID + harness.view.selectedRange = NSRange(location: harness.view.text.utf16.count, length: 0) + harness.view.insertText("e") + } + } + harness.coordinator.install(replacement, in: harness.view) + if revocation == "replacement-cancel" { + harness.coordinator.parent.isCurrentOwner = { + if !canceledDuringAdmission { + canceledDuringAdmission = true + // A new cancellation must win even if native + // focus is already absent and binding is true. + harness.view.resignFirstResponder() + } + return true + } + } + } else { + harness.view.resignFirstResponder() + } + } + + _ = harness.view.perform(action, with: command) + labeledCheck(harness.capture.submitted.isEmpty, label: "\(revocation).noSubmit", check: check) + labeledCheck(harness.capture.rebaseFailures == 0, label: "\(revocation).exactEdits", check: check) + if revocation.hasPrefix("replacement") { + let expected = "authoritative replacement" + (revocation == "replacement-input" ? "e" : "") + labeledCheck( + harness.capture.text == expected && harness.view.text == expected, + label: "\(revocation).replacementText", check: check) + if revocation == "replacement-unfocused" || revocation == "replacement-account" + || revocation == "replacement-cancel" + { + labeledCheck(!harness.view.isFirstResponder, label: "\(revocation).unfocused", check: check) + } else { + labeledCheck(harness.view.isFirstResponder, label: "\(revocation).focused", check: check) + } + labeledCheck(harness.view.markedTextRange == nil, label: "\(revocation).unmarked", check: check) + if revocation == "replacement-cancel" { + labeledCheck(canceledDuringAdmission, label: "\(revocation).cancellationObserved", check: check) + } + if revocation == "replacement-account" { + labeledCheck( + harness.coordinator.owner?.generation != priorGeneration, + label: "\(revocation).newGeneration", check: check) + } + if revocation == "replacement-input" { + labeledCheck(focusInputCount == 1, label: "\(revocation).focusInputOnce", check: check) + labeledCheck(baselineBeforeFocus, label: "\(revocation).baselineBeforeFocus", check: check) + labeledCheck( + harness.capture.edits.last + == DictationTextEdit( + beforeText: "authoritative replacement", + rangeUTF16: NSRange(location: "authoritative replacement".utf16.count, length: 0), + replacement: "e"), + label: "\(revocation).exactInput", check: check) + } + } else { + labeledCheck(harness.capture.text.hasPrefix("abcd"), label: "\(revocation).prefix", check: check) + if revocation == "composition" { + labeledCheck(harness.view.markedTextRange != nil, label: "\(revocation).marked", check: check) + } else { + labeledCheck(!harness.view.isFirstResponder, label: "\(revocation).unfocused", check: check) + } + } + } + } + + private static func postReturnTextStaysWithAuthoritativeSuccessor(_ check: (Bool) -> Void) { + let harness = Harness(text: "ab", marker: 2, focus: true) + defer { harness.close() } + wireNativeInput(harness) + guard let command = returnCommand(harness.view), let action = command.action else { check(false); return } + var nextText = "" + var nextSends = 0 + let next = ComposerEditor( + text: Binding(get: { nextText }, set: { nextText = $0 }), focused: .constant(true), ownerID: UUID(), + accountGeneration: harness.coordinator.parent.accountGeneration, placeholder: "Synthetic next draft", + onSubmit: { nextSends += 1 }) + harness.capture.afterSubmit = { harness.coordinator.install(next, in: harness.view) } + harness.view.beforeResignFirstResponderForProbe = { + harness.view.beforeResignFirstResponderForProbe = nil + harness.view.insertText("c") + } + _ = harness.view.perform(action, with: command) + + check(harness.capture.submitted == ["abc"] && harness.view.text.isEmpty) + harness.view.insertText("d") + + check(nextText == "d" && nextSends == 0) + check(harness.capture.submitted == ["abc"]) + } + + private static func sameOwnerSubmitClearsBeforePendingNativeInsertion(_ check: (Bool) -> Void) { + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + wireNativeInput(harness) + guard let command = returnCommand(harness.view), let action = command.action else { check(false); return } + harness.capture.afterSubmit = { + harness.capture.text = ""; harness.capture.focused = false + harness.capture.marker = DictationInsertionAnchor(text: "", offsetUTF16: 0) + } + // Actual fifteenth ordering: trailing d is proposed before Send's + // callback, but its insertText is delivered after the callback. + check( + harness.coordinator.textView( + harness.view, shouldChangeTextIn: NSRange(location: 3, length: 0), replacementText: "d")) + _ = harness.view.perform(action, with: command) + + check(harness.capture.submitted == ["abc"]) + check(harness.view.text.isEmpty && harness.coordinator.reportedText.isEmpty) + check(harness.coordinator.requests.isEmpty && harness.view.isFirstResponder) + harness.view.insertText("d") + check(harness.capture.text == "d" && harness.view.text == "d") + check( + harness.capture.edits == [ + DictationTextEdit(beforeText: "", rangeUTF16: NSRange(location: 0, length: 0), replacement: "d") + ]) + check(harness.capture.rebaseFailures == 0) + } + + private static func rotatedSubmitRoutesPendingInputOnlyToSuccessor(_ check: (Bool) -> Void) { + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + wireNativeInput(harness) + guard let command = returnCommand(harness.view), let action = command.action else { check(false); return } + let successor = Capture(text: "", marker: 0) + let nextOwner = UUID() + let next = ComposerEditor( + text: Binding(get: { successor.text }, set: { successor.text = $0 }), + focused: Binding(get: { harness.capture.focused }, set: { harness.capture.focused = $0 }), + ownerID: nextOwner, accountGeneration: harness.coordinator.parent.accountGeneration, + placeholder: "Synthetic next draft", onChange: { successor.receive($0, $1) }, + onSubmit: { successor.submitted.append(successor.text) }) + harness.coordinator.parent.refreshAfterSubmit = { next } + harness.capture.afterSubmit = { + // Shipping's captured old UUID no longer resolves once Core + // detaches it. Do not make its getter dynamically adopt a new UUID. + harness.capture.retired = true; harness.capture.focused = false + } + check( + harness.coordinator.textView( + harness.view, shouldChangeTextIn: NSRange(location: 3, length: 0), replacementText: "d")) + _ = harness.view.perform(action, with: command) + + check(harness.capture.submitted == ["abc"] && harness.capture.text == "abc") + check(harness.coordinator.owner?.draft == nextOwner && harness.view.text.isEmpty) + check(harness.view.isFirstResponder && harness.coordinator.requests.isEmpty) + harness.view.insertText("d") + check(successor.text == "d" && successor.submitted.isEmpty) + check(harness.capture.text == "abc" && harness.capture.edits.isEmpty) + check( + successor.edits == [ + DictationTextEdit(beforeText: "", rangeUTF16: NSRange(location: 0, length: 0), replacement: "d") + ]) + check(successor.rebaseFailures == 0 && harness.capture.rebaseFailures == 0) + } + + private static func rejectedAndReservedSubmitRetainCurrentDraft(_ check: (Bool) -> Void) { + for reserved in [false, true] { + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + wireNativeInput(harness) + let owner = harness.coordinator.owner + guard let command = returnCommand(harness.view), let action = command.action else { + check(false); continue + } + harness.capture.afterSubmit = { if reserved { harness.capture.focused = false } } + harness.coordinator.parent.refreshAfterSubmit = { harness.coordinator.parent } + check( + harness.coordinator.textView( + harness.view, shouldChangeTextIn: NSRange(location: 3, length: 0), replacementText: "d")) + _ = harness.view.perform(action, with: command) + + check(harness.coordinator.owner == owner && harness.capture.text == "abc" && harness.view.text == "abc") + check(harness.view.isFirstResponder) + harness.view.insertText("d") + check(harness.capture.text == "abcd" && harness.capture.submitted == ["abc"]) + check( + harness.capture.edits == [ + DictationTextEdit( + beforeText: "abc", rangeUTF16: NSRange(location: 3, length: 0), replacement: "d") + ]) + check(harness.capture.rebaseFailures == 0) + } + } + + private static func reservedOwnerAppliesLaterAuthoritativeSuccessor(_ check: (Bool) -> Void) { + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + wireNativeInput(harness) + let oldOwner = harness.coordinator.owner + guard let command = returnCommand(harness.view), let action = command.action else { check(false); return } + harness.capture.afterSubmit = { harness.capture.focused = false } + _ = harness.view.perform(action, with: command) + + check(harness.coordinator.owner == oldOwner && harness.view.text == "abc") + let successor = Capture(text: "", marker: 0) + let next = ComposerEditor( + text: Binding(get: { successor.text }, set: { successor.text = $0 }), focused: .constant(false), + ownerID: UUID(), accountGeneration: harness.coordinator.parent.accountGeneration, + placeholder: "Synthetic asynchronously validated draft", onChange: { successor.receive($0, $1) }, + onSubmit: {}) + harness.capture.retired = true + // This is the ordinary SwiftUI install once exact-body validation + // really detaches the draft; reserved admission itself never clears it. + harness.coordinator.install(next, in: harness.view) + check(harness.view.text.isEmpty && harness.capture.text == "abc") + harness.view.insertText("d") + check(successor.text == "d" && successor.rebaseFailures == 0) + check(harness.capture.submitted == ["abc"]) + } + + private static func submitRefreshCannotReplaceReentrantOwner(_ check: (Bool) -> Void) { + for sameOwner in [false, true] { + for duringRefresh in [false, true] { + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + wireNativeInput(harness) + let replacement = ComposerEditor( + text: .constant("replacement"), focused: .constant(false), + ownerID: sameOwner ? harness.coordinator.parent.ownerID : UUID(), + placeholder: "Replacement", onSubmit: {}) + let stale = harness.coordinator.parent + var refreshCalls = 0 + harness.coordinator.parent.refreshAfterSubmit = { + refreshCalls += 1 + if duringRefresh { harness.coordinator.install(replacement, in: harness.view) } + return stale + } + harness.capture.afterSubmit = { + if !duringRefresh { harness.coordinator.install(replacement, in: harness.view) } + } + guard let command = returnCommand(harness.view), let action = command.action else { + check(false); continue + } + _ = harness.view.perform(action, with: command) + check(refreshCalls == (duringRefresh ? 1 : 0)) + check(harness.coordinator.owner?.draft == replacement.ownerID && harness.view.text == "replacement") + check(harness.capture.submitted == ["abc"]) + } + } + } + + private static func submitRefreshRejectsNilAndDifferentAccount(_ check: (Bool) -> Void) { + for differentAccount in [false, true] { + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + wireNativeInput(harness) + let owner = harness.coordinator.owner + let foreign = ComposerEditor( + text: .constant("foreign"), focused: .constant(false), ownerID: UUID(), accountGeneration: UUID(), + placeholder: "Foreign account", onSubmit: {}) + harness.coordinator.parent.refreshAfterSubmit = { differentAccount ? foreign : nil } + guard let command = returnCommand(harness.view), let action = command.action else { + check(false); continue + } + _ = harness.view.perform(action, with: command) + _ = harness.view.perform(action, with: command) + + check(harness.coordinator.owner == owner && harness.view.text == "abc") + check(harness.capture.submitted == ["abc"]) + } + } + + private static func postSubmitPreservesExactInputDuringNativeAssignment(_ check: (Bool) -> Void) { + checkPostSubmitReentrantInput(duringFocus: false, check) + } + + private static func postSubmitPreservesExactInputDuringFocusChange(_ check: (Bool) -> Void) { + checkPostSubmitReentrantInput(duringFocus: true, check) + } + + private static func checkPostSubmitReentrantInput(duringFocus: Bool, _ check: (Bool) -> Void) { + for rotateOwner in [false, true] { + for nestedBaselineRender in [false, true] { + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + wireNativeInput(harness) + let successor = rotateOwner ? Capture(text: "", marker: 0) : harness.capture + let next = ComposerEditor( + text: Binding(get: { successor.text }, set: { successor.text = $0 }), + focused: .constant(false), ownerID: rotateOwner ? UUID() : harness.coordinator.parent.ownerID, + accountGeneration: harness.coordinator.parent.accountGeneration, + placeholder: "Synthetic successor", onChange: { successor.receive($0, $1) }, onSubmit: {}) + harness.coordinator.parent.refreshAfterSubmit = { next } + harness.capture.afterSubmit = { + harness.capture.focused = false + if rotateOwner { + harness.capture.retired = true + } else { + harness.capture.text = ""; + harness.capture.marker = DictationInsertionAnchor(text: "", offsetUTF16: 0) + } + } + var observations: [Bool] = [] + let insertion = { + harness.coordinator.afterNativeTextAssignmentForProbe = nil + harness.view.beforeBecomeFirstResponderForProbe = nil + observations.append(harness.coordinator.installing && harness.view.text.isEmpty) + harness.view.insertText("d") + // The complete owned operation arrived inside the setter/focus + // transaction; no model callback runs before the outer unwind. + observations.append(successor.text.isEmpty && harness.view.text == "d") + if nestedBaselineRender { + let stale = ComposerEditor( + text: .constant(""), focused: .constant(false), + ownerID: next.ownerID, accountGeneration: next.accountGeneration, + placeholder: "Older committed render", onSubmit: {}) + harness.coordinator.install(stale, in: harness.view) + observations.append(harness.view.text == "d") + } + } + if duringFocus { + harness.view.beforeBecomeFirstResponderForProbe = insertion + } else { + harness.coordinator.afterNativeTextAssignmentForProbe = insertion + } + guard let command = returnCommand(harness.view), let action = command.action else { + check(false); continue + } + // The old draft's d proposal precedes Submit; its actual native + // insertion is delivered only inside authoritative installation. + check( + harness.coordinator.textView( + harness.view, + shouldChangeTextIn: NSRange(location: 3, length: 0), replacementText: "d")) + _ = harness.view.perform(action, with: command) + + check(observations.count == (nestedBaselineRender ? 3 : 2)) + for observation in observations { check(observation) } + check(harness.capture.submitted == ["abc"]) + check(successor.text == "d" && harness.view.text == "d" && harness.coordinator.reportedText == "d") + check(harness.view.selectedRange == NSRange(location: 1, length: 0)) + check( + successor.edits == [ + DictationTextEdit( + beforeText: "", rangeUTF16: NSRange(location: 0, length: 0), replacement: "d") + ]) + check(successor.rebaseFailures == 0 && !harness.coordinator.installing) + check(harness.view.isFirstResponder) + if rotateOwner { check(harness.capture.text == "abc" && harness.capture.edits.isEmpty) } + } + } + } + + private static func postSubmitReplacementRevokesUnpublishedNativeInput(_ check: (Bool) -> Void) { + for replacementKind in ["model", "owner", "account"] { + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + wireNativeInput(harness) + harness.capture.afterSubmit = { + harness.capture.text = ""; harness.capture.focused = false + harness.capture.marker = DictationInsertionAnchor(text: "", offsetUTF16: 0) + } + let replacementText = replacementKind == "model" ? "authoritative" : "" + let replacementCapture = Capture(text: replacementText, marker: replacementText.utf16.count) + let replacement = ComposerEditor( + text: Binding(get: { replacementCapture.text }, set: { replacementCapture.text = $0 }), + focused: .constant(false), + ownerID: replacementKind == "model" ? harness.coordinator.parent.ownerID : UUID(), + accountGeneration: replacementKind == "account" + ? UUID() : harness.coordinator.parent.accountGeneration, + placeholder: "Replacement", onChange: { replacementCapture.receive($0, $1) }, onSubmit: {}) + harness.coordinator.afterNativeTextAssignmentForProbe = { + harness.coordinator.afterNativeTextAssignmentForProbe = nil + harness.view.insertText("d") + let oldBaselineRender = harness.coordinator.parent + harness.coordinator.install(replacement, in: harness.view) + harness.coordinator.install(oldBaselineRender, in: harness.view) + harness.view.insertText("e") + } + guard let command = returnCommand(harness.view), let action = command.action else { + check(false); continue + } + _ = harness.view.perform(action, with: command) + check(harness.capture.submitted == ["abc"] && harness.capture.text.isEmpty) + check(harness.capture.edits.isEmpty && replacementCapture.edits.isEmpty) + check(replacementCapture.text == replacementText && harness.view.text == replacementText) + check(harness.coordinator.owner?.draft == replacement.ownerID) + } + } + + private static func postSubmitUnknownNativeMutationKeepsMarkerFailure(_ check: (Bool) -> Void) { + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + wireNativeInput(harness) + harness.capture.afterSubmit = { + harness.capture.text = ""; harness.capture.focused = false + harness.capture.marker = DictationInsertionAnchor(text: "", offsetUTF16: 0) + } + harness.coordinator.afterNativeTextAssignmentForProbe = { + harness.coordinator.afterNativeTextAssignmentForProbe = nil + harness.view.text = "unknown" + harness.view.selectedRange = NSRange(location: 7, length: 0) + harness.view.insertText("d") + } + guard let command = returnCommand(harness.view), let action = command.action else { check(false); return } + _ = harness.view.perform(action, with: command) + check(harness.capture.text == "unknownd" && harness.capture.submitted == ["abc"]) + check(harness.capture.rebaseFailures == 1 && harness.capture.edits.count == 1) + check(harness.capture.edits.first?.resultingText == nil) + } + + private static func postSubmitNestedRenderWithoutInputStillUpdates(_ check: (Bool) -> Void) { + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + wireNativeInput(harness) + harness.capture.afterSubmit = { + harness.capture.text = ""; harness.capture.focused = false + } + harness.coordinator.afterNativeTextAssignmentForProbe = { + harness.coordinator.afterNativeTextAssignmentForProbe = nil + let current = harness.coordinator.parent + let latest = ComposerEditor( + text: current.$text, focused: current.$focused, + ownerID: current.ownerID, accountGeneration: current.accountGeneration, + placeholder: "Latest render", onChange: current.onChange, onSubmit: current.onSubmit) + harness.coordinator.install(latest, in: harness.view) + } + guard let command = returnCommand(harness.view), let action = command.action else { check(false); return } + _ = harness.view.perform(action, with: command) + check(harness.capture.submitted == ["abc"] && harness.view.text.isEmpty) + check(harness.view.placeholderLabel.text == "Latest render") + } + + private static func postSubmitStaleOwnerRenderCannotReclaimSuccessor(_ check: (Bool) -> Void) { + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + wireNativeInput(harness) + let oldID = harness.coordinator.parent.ownerID + let nextID = UUID() + var visibleID = oldID + harness.coordinator.parent.isCurrentOwner = { visibleID == oldID } + let stale = harness.coordinator.parent + let successor = Capture(text: "", marker: 0) + let next = ComposerEditor( + text: Binding(get: { successor.text }, set: { successor.text = $0 }), focused: .constant(false), + ownerID: nextID, accountGeneration: harness.coordinator.parent.accountGeneration, + isCurrentOwner: { visibleID == nextID }, placeholder: "Successor", + onChange: { successor.receive($0, $1) }, onSubmit: {}) + harness.coordinator.parent.refreshAfterSubmit = { next } + harness.capture.afterSubmit = { + visibleID = nextID; harness.capture.retired = true; harness.capture.focused = false + } + harness.coordinator.afterNativeTextAssignmentForProbe = { + harness.coordinator.afterNativeTextAssignmentForProbe = nil + harness.view.insertText("d") + harness.coordinator.install(stale, in: harness.view) + } + guard let command = returnCommand(harness.view), let action = command.action else { check(false); return } + _ = harness.view.perform(action, with: command) + check(harness.capture.submitted == ["abc"] && harness.capture.text == "abc") + check(successor.text == "d" && harness.coordinator.owner?.draft == nextID) + harness.coordinator.install(stale, in: harness.view) + check(successor.text == "d" && harness.view.text == "d") + harness.view.insertText("e") + check(successor.text == "de" && successor.rebaseFailures == 0 && harness.capture.edits.isEmpty) + } + + private static func postSubmitEqualBaselineRuleEndsAtOuterUnwind(_ check: (Bool) -> Void) { + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + wireNativeInput(harness) + harness.capture.afterSubmit = { + harness.capture.text = ""; harness.capture.focused = false + harness.capture.marker = DictationInsertionAnchor(text: "", offsetUTF16: 0) + } + harness.coordinator.afterNativeTextAssignmentForProbe = { + harness.coordinator.afterNativeTextAssignmentForProbe = nil + harness.view.insertText("d") + harness.coordinator.install(harness.coordinator.parent, in: harness.view) + } + guard let command = returnCommand(harness.view), let action = command.action else { check(false); return } + _ = harness.view.perform(action, with: command) + check(harness.capture.text == "d" && harness.view.text == "d") + // A later same-owner external clear is authoritative. The special + // linearization rule never survives the synchronous Submit apply. + harness.capture.text = "" + harness.coordinator.install(harness.coordinator.parent, in: harness.view) + check(harness.view.text.isEmpty && harness.coordinator.reportedText.isEmpty) + check(harness.capture.submitted == ["abc"] && harness.capture.rebaseFailures == 0) + } + + private static func ordinarySameOwnerInstallPreservesMarkedComposition(_ check: (Bool) -> Void) { + for replaceOwner in [false, true] { + let harness = Harness(text: "a", marker: 1, focus: true) + defer { harness.close() } + wireNativeInput(harness) + harness.view.setMarkedText("に", selectedRange: NSRange(location: 1, length: 0)) + let markedText = harness.view.text + let selectedRange = harness.view.selectedRange + check(harness.view.markedTextRange != nil && harness.capture.text == markedText) + harness.coordinator.install(harness.coordinator.parent, in: harness.view) + check(harness.view.markedTextRange != nil) + check(harness.view.text == markedText && harness.view.selectedRange == selectedRange) + if replaceOwner { + let replacement = ComposerEditor( + text: .constant("external"), focused: .constant(false), + ownerID: UUID(), accountGeneration: harness.coordinator.parent.accountGeneration, + placeholder: "Replacement", onSubmit: {}) + harness.coordinator.install(replacement, in: harness.view) + } else { + harness.capture.text = "external" + harness.coordinator.install(harness.coordinator.parent, in: harness.view) + } + check(harness.view.markedTextRange == nil && harness.view.text == "external") + check(harness.capture.submitted.isEmpty) + } + } + + private static func refusedResignationDoesNotSendOrClear(_ check: (Bool) -> Void) { + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + wireNativeInput(harness) + guard let command = returnCommand(harness.view), let action = command.action else { check(false); return } + harness.view.refuseResignationForProbe = true + _ = harness.view.perform(action, with: command) + check(harness.view.isFirstResponder && harness.capture.focused) + check(harness.capture.text == "abc" && harness.capture.submitted.isEmpty) + check(harness.capture.edits.isEmpty && harness.capture.rebaseFailures == 0) + harness.view.refuseResignationForProbe = false + } + + private static func compositionDuringResignationCancelsWithoutCommit(_ check: (Bool) -> Void) { + for modifiers in [UIKeyModifierFlags(), .shift] { + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + wireNativeInput(harness) + guard let command = returnCommand(harness.view, modifiers: modifiers), let action = command.action + else { check(false); continue } + harness.view.beforeResignFirstResponderForProbe = { + harness.view.beforeResignFirstResponderForProbe = nil + harness.view.setMarkedText("に", selectedRange: NSRange(location: 1, length: 0)) + } + _ = harness.view.perform(action, with: command) + check(harness.view.markedTextRange != nil && harness.view.isFirstResponder) + check(harness.view.text == "abcに" && harness.capture.text == "abcに") + check(harness.capture.submitted.isEmpty && !harness.view.text.contains("\n")) + check(harness.capture.rebaseFailures == 0) + } + } + + private static func revokedOwnerWithoutInstallationCannotSend(_ check: (Bool) -> Void) { + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + wireNativeInput(harness) + var active = true + harness.coordinator.parent.isCurrentOwner = { active } + guard let command = returnCommand(harness.view), let action = command.action else { check(false); return } + harness.view.beforeResignFirstResponderForProbe = { + harness.view.beforeResignFirstResponderForProbe = nil + active = false + } + _ = harness.view.perform(action, with: command) + check(harness.capture.submitted.isEmpty && harness.capture.text == "abc") + check(!harness.view.isFirstResponder) + } + + private static func successorIsInstalledBeforeHardwareFocusResumes(_ check: (Bool) -> Void) { + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + wireNativeInput(harness) + let successor = Capture(text: "", marker: 0) + let successorID = UUID() + let next = ComposerEditor( + text: Binding(get: { successor.text }, set: { successor.text = $0 }), + focused: Binding(get: { successor.focused }, set: { successor.focused = $0 }), + ownerID: successorID, accountGeneration: harness.coordinator.parent.accountGeneration, + placeholder: "Successor", onChange: { successor.receive($0, $1) }, onSubmit: {}) + harness.capture.afterSubmit = { harness.capture.retired = true } + harness.coordinator.parent.refreshAfterSubmit = { next } + var resumeCount = 0 + var successorInstalled = false + var baselineEmpty = false + var submittedBeforeFocus = false + harness.view.beforeBecomeFirstResponderForProbe = { + harness.view.beforeBecomeFirstResponderForProbe = nil + resumeCount += 1 + successorInstalled = harness.coordinator.owner?.draft == successorID + baselineEmpty = harness.coordinator.reportedText.isEmpty && harness.view.text.isEmpty + submittedBeforeFocus = harness.capture.submitted == ["abc"] + harness.view.insertText("d") + } + guard let command = returnCommand(harness.view), let action = command.action else { check(false); return } + _ = harness.view.perform(action, with: command) + check(successorInstalled) + check(baselineEmpty) + check(submittedBeforeFocus) + check(resumeCount == 1 && harness.view.isFirstResponder && successor.focused) + check(successor.text == "d" && successor.edits.map(\.replacement) == ["d"]) + check(successor.rebaseFailures == 0 && harness.capture.edits.isEmpty) + check(harness.capture.text == "abc" && harness.capture.submitted == ["abc"]) + } + + private static func sendAndDismissForPresentation(_ harness: Harness) -> Bool { + wireNativeInput(harness) + harness.capture.afterSubmit = { + harness.capture.text = "" + harness.capture.marker = DictationInsertionAnchor(text: "", offsetUTF16: 0) + harness.capture.focused = false + } + guard let command = returnCommand(harness.view), let action = command.action else { return false } + _ = harness.view.perform(action, with: command) + return harness.capture.submitted == ["abc"] && harness.view.softwareKeyboardSuppressed + } + + private static func hardwareSendDismissalPreservesSuccessorKeyboardInput(_ check: (Bool) -> Void) { + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + var suppressedBeforeFocus = false + harness.view.beforeBecomeFirstResponderForProbe = { + harness.view.beforeBecomeFirstResponderForProbe = nil + suppressedBeforeFocus = + harness.view.softwareKeyboardSuppressed + && harness.view.inputView?.frame.height == 0 && harness.view.text.isEmpty + harness.view.insertText("d") + } + check(sendAndDismissForPresentation(harness)) + check(suppressedBeforeFocus && harness.view.isFirstResponder) + check(harness.capture.text == "d" && harness.capture.edits.map(\.replacement) == ["d"]) + harness.coordinator.install(harness.coordinator.parent, in: harness.view) + check(harness.view.softwareKeyboardSuppressed && harness.view.inputView != nil) + harness.view.insertText("e") + check(harness.capture.text == "de" && harness.capture.submitted == ["abc"]) + check(harness.capture.rebaseFailures == 0) + } + + private static func keyboardPresentationPreservesSendDisposition(_ check: (Bool) -> Void) { + for disposition in ["rejected", "reserved", "shift", "refused"] { + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + wireNativeInput(harness) + if disposition == "reserved" { harness.capture.afterSubmit = { harness.capture.focused = false } } + if disposition == "refused" { harness.view.refuseResignationForProbe = true } + guard let command = returnCommand(harness.view, modifiers: disposition == "shift" ? .shift : []), + let action = command.action + else { check(false); continue } + _ = harness.view.perform(action, with: command) + check(harness.view.softwareKeyboardSuppressed == (disposition == "reserved")) + check((harness.view.inputView != nil) == (disposition == "reserved")) + check(harness.capture.text == (disposition == "shift" ? "abc\n" : "abc")) + check(harness.capture.rebaseFailures == 0) + } + } + + private static func explicitEditingRestoresSystemKeyboardWithExactInput(_ check: (Bool) -> Void) { + for accessibility in [false, true] { + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + check(sendAndDismissForPresentation(harness)) + var reloads = 0 + var baselineBeforeInput = false + harness.view.beforeReloadInputViewsForProbe = { + harness.view.beforeReloadInputViewsForProbe = nil + reloads += 1 + baselineBeforeInput = + harness.view.inputView == nil + && harness.coordinator.reportedText.isEmpty && harness.view.text.isEmpty + harness.view.insertText("d") + } + if accessibility { + _ = harness.view.accessibilityActivate() + } else { + harness.view.showSoftwareKeyboardForInteraction() + } + check(reloads == 1 && baselineBeforeInput) + check(!harness.view.softwareKeyboardSuppressed && harness.view.inputView == nil) + check(harness.capture.text == "d" && harness.view.text == "d") + check(harness.capture.edits.map(\.replacement) == ["d"]) + check(harness.capture.rebaseFailures == 0 && harness.capture.submitted == ["abc"]) + } + } + + private static func ordinaryOwnerAndTeardownResetKeyboardPresentation(_ check: (Bool) -> Void) { + for newGeneration in [false, true] { + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + check(sendAndDismissForPresentation(harness)) + let next = ComposerEditor( + text: .constant("new draft"), focused: .constant(true), ownerID: UUID(), + accountGeneration: newGeneration ? UUID() : harness.coordinator.parent.accountGeneration, + placeholder: "Replacement", onSubmit: {}) + harness.coordinator.install(next, in: harness.view) + check(harness.view.inputView == nil && !harness.view.softwareKeyboardSuppressed) + check(harness.view.text == "new draft" && harness.capture.text.isEmpty) + } + for replaceDuringReload in [false, true] { + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + check(sendAndDismissForPresentation(harness)) + let nextCapture = Capture(text: "new draft", marker: 9) + nextCapture.focused = true + let nextID = UUID() + let revokedID = UUID() + let next = ComposerEditor( + text: Binding(get: { nextCapture.text }, set: { nextCapture.text = $0 }), + focused: Binding(get: { nextCapture.focused }, set: { nextCapture.focused = $0 }), + ownerID: nextID, accountGeneration: UUID(), placeholder: "Next owner", + onChange: { nextCapture.receive($0, $1) }, onSubmit: {}) + var baselineBeforeReload = false + harness.view.beforeReloadInputViewsForProbe = { + harness.view.beforeReloadInputViewsForProbe = nil + baselineBeforeReload = + harness.coordinator.owner?.draft == nextID + && harness.coordinator.reportedText == "new draft" && harness.view.text == "new draft" + harness.view.insertText("d") + if replaceDuringReload { + let revoked = ComposerEditor( + text: .constant("authoritative replacement"), focused: .constant(false), ownerID: revokedID, + accountGeneration: UUID(), placeholder: "Replacement", onSubmit: {}) + harness.coordinator.install(revoked, in: harness.view) + } + } + harness.coordinator.install(next, in: harness.view) + check(baselineBeforeReload) + check(harness.view.inputView == nil && !harness.view.softwareKeyboardSuppressed) + check(harness.capture.text.isEmpty && harness.capture.edits.isEmpty) + if replaceDuringReload { + check(harness.coordinator.owner?.draft == revokedID && !harness.view.isFirstResponder) + check(harness.view.text == "authoritative replacement" && nextCapture.text == "new draft") + check(nextCapture.edits.isEmpty) + } else { + check(harness.coordinator.owner?.draft == nextID) + check(nextCapture.text == "new draftd" && harness.view.text == "new draftd") + check( + nextCapture.edits == [ + DictationTextEdit( + beforeText: "new draft", rangeUTF16: NSRange(location: 9, length: 0), replacement: "d") + ]) + check(nextCapture.rebaseFailures == 0) + } + } + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + check(sendAndDismissForPresentation(harness)) + ComposerEditor.dismantleUIView(harness.view, coordinator: harness.coordinator) + check(harness.view.inputView == nil && !harness.view.softwareKeyboardSuppressed) + check(!harness.view.isFirstResponder && !harness.coordinator.valid) + } + + private static func keyboardPresentationReentryCannotReclaimReplacement(_ check: (Bool) -> Void) { + for detach in [false, true] { + let retained = Harness(text: "abc", marker: 3, focus: true) + defer { retained.close() } + check(sendAndDismissForPresentation(retained)) + retained.view.beforeReloadInputViewsForProbe = { + retained.view.beforeReloadInputViewsForProbe = nil + retained.view.insertText("d") + retained.capture.focused = false + if detach { + retained.view.removeFromSuperview() + } else { + retained.view.resignFirstResponder() + } + } + retained.view.showSoftwareKeyboardForInteraction() + check(retained.capture.text == "d" && retained.view.text == "d") + check( + retained.capture.edits == [ + DictationTextEdit(beforeText: "", rangeUTF16: NSRange(location: 0, length: 0), replacement: "d") + ]) + check(retained.capture.rebaseFailures == 0 && retained.capture.submitted == ["abc"]) + check(!retained.view.isFirstResponder && !retained.capture.focused) + if detach { check(retained.view.window == nil) } + } + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + check(sendAndDismissForPresentation(harness)) + let nextID = UUID() + var replaced = false + harness.view.beforeReloadInputViewsForProbe = { + harness.view.beforeReloadInputViewsForProbe = nil + replaced = true + let next = ComposerEditor( + text: .constant("replacement"), focused: .constant(false), ownerID: nextID, + accountGeneration: UUID(), placeholder: "Replacement", onSubmit: {}) + harness.coordinator.install(next, in: harness.view) + } + harness.view.showSoftwareKeyboardForInteraction() + check(replaced && harness.coordinator.owner?.draft == nextID) + check(harness.view.text == "replacement" && !harness.view.isFirstResponder) + check(harness.view.inputView == nil && !harness.view.softwareKeyboardSuppressed) + check(harness.capture.text.isEmpty && harness.capture.submitted == ["abc"]) + } + + private static func keyboardRestorationPreservesMarkedAndRevokedOwners(_ check: (Bool) -> Void) { + for revoked in [false, true] { + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + check(sendAndDismissForPresentation(harness)) + if revoked { + harness.coordinator.parent.isCurrentOwner = { false } + } else { + harness.view.setMarkedText("に", selectedRange: NSRange(location: 1, length: 0)) + check(harness.view.markedTextRange != nil) + } + let text = harness.view.text + let selection = harness.view.selectedRange + harness.view.showSoftwareKeyboardForInteraction() + check(harness.view.softwareKeyboardSuppressed && harness.view.inputView != nil) + check(harness.view.text == text && harness.view.selectedRange == selection) + if !revoked { check(harness.view.markedTextRange != nil) } + check(harness.capture.submitted == ["abc"]) + } + } + + private static func compactInteractionReclaimsRetainedKeyboardFocus(_ check: (Bool) -> Void) { + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + wireNativeInput(harness) + var interactions = 0 + var editor = harness.coordinator.parent + editor.isCompact = true + editor.onEditingInteraction = { + interactions += 1; return true + } + harness.coordinator.install(editor, in: harness.view) + check(harness.view.isFirstResponder && !harness.view.softwareKeyboardSuppressed) + var reloads = 0 + harness.view.beforeReloadInputViewsForProbe = { + harness.view.beforeReloadInputViewsForProbe = nil + reloads += 1 + harness.view.insertText("d") + } + harness.view.showSoftwareKeyboardForInteraction() + check(interactions == 1 && reloads == 1 && harness.capture.focused) + check(harness.view.text == "abcd" && harness.capture.text == "abcd") + check(harness.capture.edits.map(\.replacement) == ["d"] && harness.capture.rebaseFailures == 0) + check(harness.capture.submitted.isEmpty && harness.view.markedTextRange == nil) + } + + private static func compactInteractionStartsUnfocusedEditorWithExactInput(_ check: (Bool) -> Void) { + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + wireNativeInput(harness) + harness.view.resignFirstResponder() + check(!harness.capture.focused && !harness.view.isFirstResponder) + var interactions = 0 + var editor = harness.coordinator.parent + editor.isCompact = true + editor.onEditingInteraction = { + interactions += 1; return true + } + harness.coordinator.install(editor, in: harness.view) + harness.view.beforeBecomeFirstResponderForProbe = { + harness.view.beforeBecomeFirstResponderForProbe = nil + harness.view.insertText("d") + } + harness.view.showSoftwareKeyboardForInteraction() + check(interactions == 1 && harness.capture.focused && harness.view.isFirstResponder) + check(harness.view.text == "abcd" && harness.capture.text == "abcd") + check(harness.capture.edits.map(\.replacement) == ["d"] && harness.capture.rebaseFailures == 0) + check(harness.capture.submitted.isEmpty && !harness.view.softwareKeyboardSuppressed) + } + + private static func compactInteractionSurvivesSameOwnerRender(_ check: (Bool) -> Void) { + for startsFocused in [false, true] { + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + wireNativeInput(harness) + if !startsFocused { harness.view.resignFirstResponder() } + var interactions = 0 + var editor = harness.coordinator.parent + editor.isCompact = true + editor.onEditingInteraction = { + interactions += 1 + var refreshed = harness.coordinator.parent + refreshed.isCompact = false + harness.coordinator.install(refreshed, in: harness.view) + return true + } + editor = ComposerEditor( + text: editor.$text, + focused: Binding( + get: { harness.capture.focused }, + set: { value in + harness.capture.focused = value + harness.coordinator.install(harness.coordinator.parent, in: harness.view) + }), + ownerID: editor.ownerID, accountGeneration: editor.accountGeneration, + isCurrentOwner: editor.isCurrentOwner, isCurrentPresentation: editor.isCurrentPresentation, + placeholder: editor.placeholder, + isCompact: editor.isCompact, accessibilityIdentifier: editor.accessibilityIdentifier, + externalSelection: editor.externalSelection, onChange: editor.onChange, + onEditingInteraction: editor.onEditingInteraction, + refreshAfterSubmit: editor.refreshAfterSubmit, onSubmit: editor.onSubmit) + harness.coordinator.install(editor, in: harness.view) + var reloads = 0 + harness.view.beforeReloadInputViewsForProbe = { reloads += 1 } + harness.view.showSoftwareKeyboardForInteraction() + check(interactions == 1 && harness.capture.focused && harness.view.isFirstResponder) + check(reloads == (startsFocused ? 1 : 0)) + check(harness.view.text == "abc" && harness.capture.text == "abc") + check(harness.capture.edits.isEmpty && harness.capture.submitted.isEmpty) + } + } + + private static func compactInteractionCannotReclaimRevokedPresentation(_ check: (Bool) -> Void) { + for replacesOwner in [false, true] { + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + wireNativeInput(harness) + harness.view.resignFirstResponder() + let nextID = UUID() + var editor = harness.coordinator.parent + editor.isCompact = true + editor.onEditingInteraction = { + if replacesOwner { + let next = ComposerEditor( + text: .constant("next"), focused: .constant(false), ownerID: nextID, + accountGeneration: UUID(), placeholder: "Next", onSubmit: {}) + harness.coordinator.install(next, in: harness.view) + return true + } + return false + } + harness.coordinator.install(editor, in: harness.view) + var reloads = 0 + harness.view.beforeReloadInputViewsForProbe = { reloads += 1 } + harness.view.showSoftwareKeyboardForInteraction() + check(reloads == 0 && !harness.view.isFirstResponder && !harness.capture.focused) + check(harness.capture.submitted.isEmpty && harness.capture.edits.isEmpty) + check(harness.view.text == (replacesOwner ? "next" : "abc")) + if replacesOwner { check(harness.coordinator.owner?.draft == nextID) } + } + } + + private static func lateNativeFocusCallbacksCannotReopenOrDismissEditor(_ check: (Bool) -> Void) { + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + wireNativeInput(harness) + harness.view.resignFirstResponder() + check(!harness.capture.focused && !harness.view.isFirstResponder) + + // Model the actual queued Begin observed after menu navigation had + // already resigned the retained native editor. It must not refocus. + harness.coordinator.textViewDidBeginEditing(harness.view) + check(!harness.capture.focused && !harness.view.isFirstResponder) + harness.coordinator.install(harness.coordinator.parent, in: harness.view) + check(!harness.capture.focused && !harness.view.isFirstResponder) + + harness.capture.focused = true + harness.coordinator.install(harness.coordinator.parent, in: harness.view) + check(harness.capture.focused && harness.view.isFirstResponder) + // A queued End from that earlier resignation also cannot dismiss + // a native editor that the current owned projection reacquired. + harness.coordinator.textViewDidEndEditing(harness.view) + check(harness.capture.focused && harness.view.isFirstResponder) + + harness.coordinator.parent.isCurrentOwner = { false } + harness.capture.focused = false + harness.coordinator.textViewDidBeginEditing(harness.view) + check(!harness.capture.focused && harness.view.isFirstResponder) + harness.view.resignFirstResponder() + harness.capture.focused = true + harness.coordinator.textViewDidEndEditing(harness.view) + check(harness.capture.focused && !harness.view.isFirstResponder) + check(harness.view.text == "abc" && harness.capture.text == "abc") + check(harness.capture.edits.isEmpty && harness.capture.submitted.isEmpty) + } + + private static func unrequestedNativeFocusCannotReopenDismissedComposer(_ check: (Bool) -> Void) { + for cachedFocusRead in [false, true] { + let harness = Harness(text: "abc", marker: 3, focus: true) + defer { harness.close() } + wireNativeInput(harness) + harness.view.resignFirstResponder() + check(!harness.capture.focused && !harness.view.isFirstResponder) + var interactions = 0 + let previous = harness.coordinator.parent + let focused: Binding = + cachedFocusRead + ? Binding(get: { false }, set: { harness.capture.focused = $0 }) : previous.$focused + let editor = ComposerEditor( + text: previous.$text, focused: focused, ownerID: previous.ownerID, + accountGeneration: previous.accountGeneration, isCurrentOwner: previous.isCurrentOwner, + isCurrentPresentation: previous.isCurrentPresentation, + placeholder: previous.placeholder, isCompact: true, + accessibilityIdentifier: previous.accessibilityIdentifier, + externalSelection: previous.externalSelection, onChange: previous.onChange, + onEditingInteraction: { + interactions += 1; return true + }, + refreshAfterSubmit: previous.refreshAfterSubmit, onSubmit: previous.onSubmit) + harness.coordinator.install(editor, in: harness.view) + + // Model the same UIKit acquisition that occurred on retained + // navigation reattachment, without a user touch or focus request. + check(!harness.view.becomeFirstResponder()) + check(!harness.view.isFirstResponder && !harness.capture.focused && interactions == 0) + + // The real restoration gesture calls this exact production + // admission before native acquisition. A SwiftUI binding whose + // cached getter still says false must not reject the owned tap. + check(harness.view.showSoftwareKeyboardForInteraction()) + check(harness.view.isFirstResponder && harness.capture.focused && interactions == 1) + check(harness.view.text == "abc" && harness.capture.text == "abc") + check(harness.capture.edits.isEmpty && harness.capture.submitted.isEmpty) + + harness.view.resignFirstResponder() + check(!harness.view.isFirstResponder) + harness.capture.focused = true + harness.coordinator.parent.isCurrentPresentation = { false } + check(!harness.view.becomeFirstResponder()) + check(!harness.view.isFirstResponder && interactions == 1) + harness.coordinator.parent.isCurrentPresentation = { true } + harness.coordinator.parent.isCurrentOwner = { false } + check(!harness.view.becomeFirstResponder()) + check(!harness.view.isFirstResponder && interactions == 1) + } + } + + private static func nativeTextInsetsKeepCaretAndPlaceholderAligned(_ check: (Bool) -> Void) { + // Caret geometry must come from the same owned, hosted native editor + // as real input. A detached UITextView can defer its text layout. + let harness = Harness(text: "", marker: 0, focus: true) + defer { harness.close() } + let view = harness.view + labeledCheck(view.window != nil, label: "geometry.hostedWindow", check: check) + labeledCheck(view.isFirstResponder, label: "geometry.nativeFocus", check: check) + view.frame.size = CGSize(width: 240, height: 44) + view.font = .preferredFont(forTextStyle: .body) + view.textContainer.lineFragmentPadding = 0 + view.placeholderLabel.text = "Message Maple" + view.placeholderLabel.font = view.font + let lineHeight = view.font?.lineHeight ?? 22 + + func layoutNativeText() { + view.setNeedsLayout() + view.layoutIfNeeded() + // Prefer TextKit 2: accessing layoutManager would otherwise + // force UIKit's production editor to fall back to TextKit 1. + if let manager = view.textLayoutManager, let range = manager.textContentManager?.documentRange { + manager.ensureLayout(for: range) + } else { + view.layoutManager.ensureLayout(for: view.textContainer) + } + view.layoutIfNeeded() + } + + for (iteration, compact) in [true, false, true].enumerated() { + view.isCompact = compact + view.frame.size.height = compact ? 44 : 110 + view.text = "" + view.selectedRange = NSRange(location: 0, length: 0) + layoutNativeText() + let caret = view.caretRect(for: view.beginningOfDocument) + let geometry = + "iteration=\(iteration);compact=\(compact);bounds=\(String(describing: view.bounds));" + + "placeholder=\(String(describing: view.placeholderLabel.frame));caret=\(String(describing: caret));" + + "insets=\(String(describing: view.textContainerInset))" + labeledCheck( + view.textContainerInset.left == 8 && view.textContainerInset.right == 8, + label: "geometry.horizontalInsets[\(geometry)]", check: check) + labeledCheck( + abs(view.placeholderLabel.frame.minX - 8) < 0.5, + label: "geometry.placeholderLeading[\(geometry)]", check: check) + labeledCheck( + view.placeholderLabel.frame.maxX <= view.bounds.width - 8 + 0.5, + label: "geometry.placeholderTrailing[\(geometry)]", check: check) + labeledCheck( + // UIKit centers its two-point caret stroke on the glyph's + // insertion position; its left edge is one point earlier. + abs(caret.midX - view.placeholderLabel.frame.minX) < 0.5, + label: "geometry.caretInsertionPosition[\(geometry)]", check: check) + if compact { + labeledCheck( + abs(view.placeholderLabel.frame.midY - view.bounds.midY) < 1, + label: "geometry.compactPlaceholderCentered[\(geometry)]", check: check) + labeledCheck( + caret.minY >= 6 && caret.maxY <= view.bounds.height, + label: "geometry.compactCaretBounds[\(geometry)]", check: check) + } else { + labeledCheck( + view.textContainerInset.top == 6 && view.textContainerInset.bottom == 6, + label: "geometry.expandedVerticalInsets[\(geometry)]", check: check) + labeledCheck( + abs(view.placeholderLabel.frame.minY - 6) < 0.5, + label: "geometry.expandedPlaceholderTop[\(geometry)]", check: check) + } + view.text = "Synthetic text" + view.selectedRange = NSRange(location: 0, length: 0) + layoutNativeText() + view.setMarkedText("に", selectedRange: NSRange(location: 1, length: 0)) + labeledCheck( + view.markedTextRange != nil, label: "geometry.\(iteration).markedInputRetained", check: check) + labeledCheck( + view.textContainerInset.left == 8 && view.textContainerInset.right == 8, + label: "geometry.\(iteration).markedHorizontalInsets", check: check) + view.unmarkText() + labeledCheck( + view.text == "にSynthetic text" && view.selectedRange.location == 1, + label: "geometry.\(iteration).committedInputAndSelection", check: check) + } + view.font = .systemFont(ofSize: 44) + view.placeholderLabel.font = view.font + view.isCompact = true + view.frame.size.height = ComposerEditorMetrics.preferredHeight( + lineHeight: view.font?.lineHeight ?? lineHeight, contentHeight: 0, compact: true) + layoutNativeText() + let largeGeometry = + "bounds=\(String(describing: view.bounds));placeholder=\(String(describing: view.placeholderLabel.frame));" + + "insets=\(String(describing: view.textContainerInset))" + labeledCheck( + view.textContainerInset.top >= 6 && view.textContainerInset.bottom >= 6, + label: "geometry.largeFontVerticalInsets[\(largeGeometry)]", check: check) + labeledCheck( + abs(view.placeholderLabel.frame.midY - view.bounds.midY) < 1, + label: "geometry.largeFontPlaceholderCentered[\(largeGeometry)]", check: check) + } + + @MainActor private final class Capture { + var text: String + var focused = false + var retired = false + var events: [(DictationEditorState, [DictationTextEdit])] = [] + var edits: [DictationTextEdit] { events.flatMap(\.1) } + var marker: DictationInsertionAnchor? + var rebaseFailures = 0 + var afterReceive: ((DictationEditorState, [DictationTextEdit]) -> Void)? + var submitted: [String] = [] + var afterSubmit: (() -> Void)? + init(text: String, marker: Int) { + self.text = text + self.marker = DictationInsertionAnchor(text: text, offsetUTF16: marker) + } + func receive(_ state: DictationEditorState, _ edits: [DictationTextEdit]) { + events.append((state, edits)) + for edit in edits where marker?.rebase(using: edit) != true { rebaseFailures += 1 } + afterReceive?(state, edits) + } + } + + @MainActor private struct Harness { + let capture: Capture + let view: ComposerTextView + let coordinator: ComposerEditor.Coordinator + init(text: String, marker: Int, focus: Bool = false) { + let capture = Capture(text: text, marker: marker) + capture.focused = focus + let editor = ComposerEditor( + text: Binding( + get: { capture.retired ? "" : capture.text }, set: { if !capture.retired { capture.text = $0 } } + ), + focused: Binding(get: { capture.focused }, set: { capture.focused = $0 }), ownerID: UUID(), + placeholder: "Synthetic ordering probe", + onChange: { capture.receive($0, $1) }, + onSubmit: { + capture.submitted.append(capture.text); capture.afterSubmit?() + }) + let view = ComposerTextView(frame: .zero, textContainer: nil) + let coordinator = editor.makeCoordinator() + // Direct coordinator calls control the order; no real delegate or + // storage callbacks can silently repair the injected sequence. + coordinator.view = view + coordinator.install(editor, in: view) + coordinator.publish() + capture.events.removeAll() + if focus, + let window = UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }) + .flatMap(\.windows).first(where: \.isKeyWindow) + { + view.frame = CGRect(x: 8, y: 8, width: 220, height: 44) + window.addSubview(view) + view.becomeFirstResponder() + } + self.capture = capture + self.view = view + self.coordinator = coordinator + } + func close() { + coordinator.valid = false + coordinator.parent.refreshAfterSubmit = nil + coordinator.afterNativeTextAssignmentForProbe = nil + view.beforeResignFirstResponderForProbe = nil + view.beforeBecomeFirstResponderForProbe = nil + view.beforeReloadInputViewsForProbe = nil + view.refuseResignationForProbe = false + view.resignFirstResponder() + view.removeFromSuperview() + view.delegate = nil + view.pasteDelegate = nil + view.changed = nil + view.willReplace = nil + view.submit = nil + coordinator.view = nil + capture.afterReceive = nil + capture.afterSubmit = nil + } + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Composer/ComposerEditor.swift b/apps/maple-ios/Sources/MapleAppUI/Composer/ComposerEditor.swift new file mode 100644 index 000000000..994ade069 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Composer/ComposerEditor.swift @@ -0,0 +1,1510 @@ +#if os(iOS) + import MapleAppCore + import SwiftUI + import UIKit + import UniformTypeIdentifiers + + #if DEBUG + @MainActor enum ComposerFocusTrace { + static func record(_ state: String) { + let arguments = ProcessInfo.processInfo.arguments + guard arguments.contains("--maple-shipping-viewport-fixture"), + arguments.contains("--maple-shipping-focus-trace") + else { return } + // Only focus and lifecycle booleans supplied below. No account, + // content, input ranges or service data belongs in this trace. + NSLog("MapleComposerFocus %@", state) + } + } + #endif + + /// A narrow native input surface: SwiftUI owns its chrome and account/draft lifecycle. + /// UIKit supplies exact edits and marked-text ranges unavailable from TextField bindings. + struct ComposerEditor: UIViewRepresentable { + @Binding var text: String + @Binding var focused: Bool + let ownerID: UUID + var accountGeneration: UUID? = nil + var isCurrentOwner: () -> Bool = { true } + var isCurrentPresentation: () -> Bool = { true } + let placeholder: String + var isCompact = false + var accessibilityIdentifier = "chat.composer" + var externalSelection: DictationEditorState? = nil + var onChange: (DictationEditorState, [DictationTextEdit]) -> Void = { _, _ in } + var onEditingInteraction: () -> Bool = { true } + var refreshAfterSubmit: (() -> ComposerEditor?)? = nil + /// Return false to let UIKit paste the text representation instead. + var onPasteImages: (([NSItemProvider]) -> Bool)? = nil + var onSubmit: () -> Void + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + func makeUIView(context: Context) -> ComposerTextView { + let view = ComposerTextView(frame: .zero, textContainer: nil) + view.backgroundColor = .clear + view.font = .preferredFont(forTextStyle: .body) + view.adjustsFontForContentSizeCategory = true + view.textColor = .label + view.textContainerInset = UIEdgeInsets( + top: ComposerEditorMetrics.verticalInset, left: ComposerEditorMetrics.horizontalInset, + bottom: ComposerEditorMetrics.verticalInset, right: ComposerEditorMetrics.horizontalInset) + view.textContainer.lineFragmentPadding = 0 + view.autocapitalizationType = .sentences + view.returnKeyType = .default + view.isScrollEnabled = true + view.delegate = context.coordinator + view.pasteDelegate = context.coordinator + let observer = ComposerStorageObserver { [weak coordinator = context.coordinator] in + Task { @MainActor in coordinator?.publish() } + } + context.coordinator.storageObserver = observer + view.textStorage.delegate = observer + view.willReplace = { [weak coordinator = context.coordinator] before, range, replacement in + coordinator?.recordNativeReplacement(before: before, range: range, replacement: replacement) + } + view.changed = { [weak coordinator = context.coordinator] in coordinator?.publish(completedInput: true) } + view.submit = { [weak coordinator = context.coordinator] in coordinator?.submit() } + #if DEBUG + view.inputOrderObserver = { [weak coordinator = context.coordinator] event in + coordinator?.observeInputOrder(event) + } + #endif + context.coordinator.view = view + context.coordinator.install(self, in: view) + return view + } + + func updateUIView(_ view: ComposerTextView, context: Context) { + context.coordinator.install(self, in: view) + } + + func sizeThatFits(_ proposal: ProposedViewSize, uiView: ComposerTextView, context: Context) -> CGSize? { + guard let width = proposal.width else { return nil } + let line = uiView.font?.lineHeight ?? 22 + let size = uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude)) + let placeholderHeight = uiView.placeholderLabel.sizeThatFits( + CGSize( + width: max(0, width - ComposerEditorMetrics.horizontalInset * 2), height: .greatestFiniteMagnitude) + ).height + // Keep the normal tap surface and eight-line cap, then honor the + // composer budget without reducing the native font. UITextView scrolls + // its remaining content when chrome and the keyboard need more room. + let preferred = ComposerEditorMetrics.preferredHeight( + lineHeight: line, + contentHeight: max( + size.height, text.isEmpty ? placeholderHeight + ComposerEditorMetrics.verticalInset * 2 : 0), + compact: isCompact) + let available = proposal.height.flatMap { $0.isFinite ? max(0, $0) : nil } + let minimum = ComposerEditorMetrics.preferredHeight(lineHeight: line, contentHeight: 0, compact: true) + return CGSize(width: width, height: min(preferred, max(minimum, available ?? preferred))) + } + + static func dismantleUIView(_ view: ComposerTextView, coordinator: Coordinator) { + // Finalize the old view through its old, owner-fenced callbacks. A held + // draft must not retain a stale marked range after its input view leaves. + view.unmarkText() + coordinator.publish() + coordinator.valid = false + view.invalidateReturnCommands() + view.removeKeyboardRestorationGesture() + #if DEBUG + coordinator.observeInputOrder("dismantle") + coordinator.inputOrderObserver = nil + coordinator.afterNativeTextAssignmentForProbe = nil + view.beforeResignFirstResponderForProbe = nil + view.beforeBecomeFirstResponderForProbe = nil + view.beforeReloadInputViewsForProbe = nil + view.inputOrderObserver = nil + #endif + view.changed = nil; view.submit = nil; view.willReplace = nil + view.delegate = nil; view.pasteDelegate = nil; view.textStorage.delegate = nil + _ = view.resignFirstResponder() + view.setSoftwareKeyboardSuppressed(false) + view.text = "" + coordinator.requests.removeAll() + coordinator.reportedText = "" + coordinator.storageObserver = nil + coordinator.lastPublished = nil + } + + @MainActor final class Coordinator: NSObject, UITextViewDelegate, UITextPasteDelegate { + struct Owner: Equatable { let draft: UUID; let generation: UUID? } + var parent: ComposerEditor + weak var view: ComposerTextView? + var owner: Owner? + var reportedText = "" + var requests: [DictationTextEdit] = [] + var requestBytes = 0 + var requestOverflow = false + var storageObserver: ComposerStorageObserver? + var installing = false + var valid = true + private var installationRevision = UUID() + private static let textPasteOwnerKey = NSAttributedString.Key("MapleComposerTextPasteOwner") + private var textPasteRevision = UUID() + #if DEBUG + var pendingTextPasteReads = 0 + #endif + + private final class TextPasteOwner: NSObject { + let value: Owner + let revision: UUID + init(_ value: Owner, revision: UUID) { self.value = value; self.revision = revision } + } + + @MainActor private final class TextPasteRead { + let item: any UITextPasteItem + let owner: Owner + let revision: UUID + weak var coordinator: Coordinator? + weak var view: ComposerTextView? + init(item: any UITextPasteItem, owner: Owner, coordinator: Coordinator, view: ComposerTextView) { + self.item = item; self.owner = owner; self.coordinator = coordinator; self.view = view + revision = coordinator.textPasteRevision + } + func complete(_ text: String?) { + #if DEBUG + if let coordinator { coordinator.pendingTextPasteReads -= 1 } + #endif + guard let coordinator, let view, coordinator.valid, !coordinator.installing, + coordinator.view === view, coordinator.owner == owner, + coordinator.textPasteRevision == revision, + view.isEditable, view.markedTextRange == nil, + coordinator.parent.isCurrentOwner(), coordinator.parent.isCurrentPresentation(), let text + else { item.setNoResult(); return } + var attributes = item.defaultAttributes + attributes[Coordinator.textPasteOwnerKey] = TextPasteOwner(owner, revision: revision) + item.setResult(attributedString: NSAttributedString(string: text, attributes: attributes)) + } + } + private struct HardwareResignation { + let owner: Owner? + let installation: UUID + let action: UUID + let window: UIWindow + } + private var hardwareResignation: HardwareResignation? + private struct NativeFocusAcquisition { + let owner: Owner + let installation: UUID + let action: UUID + let window: UIWindow + } + private var nativeFocusAcquisition: NativeFocusAcquisition? + private final class SubmitInstallation { + let owner: Owner + let baseline: String + let revision: UUID + var expectedNativeText: String + var acceptsNativeEdits = false + var receivedNativeEdit = false + var replacement: ComposerEditor? + var replacementRevokesInput = false + init(owner: Owner, baseline: String, revision: UUID) { + self.owner = owner; self.baseline = baseline; self.revision = revision + expectedNativeText = baseline + } + } + private var submitInstallation: SubmitInstallation? + #if DEBUG + var afterNativeTextAssignmentForProbe: (() -> Void)? + #endif + var publicationScheduled = false + var lastPublished: DictationEditorState? + #if DEBUG + // Opt-in synthetic fixture diagnostics: lengths/ranges/event names only. + var inputOrderObserver: ((String) -> Void)? + fileprivate func observeInputOrder(_ event: @autoclosure () -> String) { + guard let observer = inputOrderObserver else { return } + observer( + "\(event()); native=\((view?.text ?? "").utf16.count); binding=\(parent.text.utf16.count); " + + "reported=\(reportedText.utf16.count); same=\(view?.text == reportedText); " + + "valid=\(valid); installing=\(installing); hasView=\(view != nil); " + + "operation=\(view?.performingInputOperation == true); " + + "editingCharacters=\(view?.textStorage.editedMask.contains(.editedCharacters) == true); " + + "scheduled=\(publicationScheduled); " + + "pending=\(requests.count); overflow=\(requestOverflow); " + + "selection=\(view?.selectedRange.location ?? -1):\(view?.selectedRange.length ?? -1); " + + "marked=\(view?.markedTextRange != nil)") + } + #endif + init(_ parent: ComposerEditor) { self.parent = parent } + + #if DEBUG + fileprivate func traceFocus(_ event: String) { + ComposerFocusTrace.record( + "\(event); focused=\(parent.focused); first=\(view?.isFirstResponder == true); " + + "compact=\(view?.isCompact == true); suppressed=\(view?.softwareKeyboardSuppressed == true); " + + "attached=\(view?.window != nil); installing=\(installing); valid=\(valid)") + } + #endif + + func install(_ parent: ComposerEditor, in view: ComposerTextView) { + #if DEBUG + traceFocus("install; incomingFocused=\(parent.focused); incomingCompact=\(parent.isCompact)") + #endif + let admittedRevision = installationRevision + guard valid, self.view === view, parent.isCurrentOwner(), installationRevision == admittedRevision + else { return } + let nextOwner = Owner(draft: parent.ownerID, generation: parent.accountGeneration) + if installing, let transaction = submitInstallation { + // SwiftUI may render the committed model while UIKit is still + // delivering input inside its native setter/focus callbacks. + if nextOwner != transaction.owner || parent.text != transaction.baseline { + transaction.replacement = parent + transaction.replacementRevokesInput = true + transaction.acceptsNativeEdits = false + } else if !transaction.replacementRevokesInput { + transaction.replacement = parent + } + return + } + #if DEBUG + observeInputOrder( + "install.begin; nextBinding=\(parent.text.utf16.count); ownerChanged=\(owner != nextOwner); " + + "generationChanged=\(owner?.generation != nextOwner.generation)") + defer { observeInputOrder("install.end; nextBinding=\(parent.text.utf16.count)") } + #endif + if refreshDuringHardwareResignation(parent, in: view, nextOwner: nextOwner) { return } + if let owner, owner != nextOwner, owner.generation == nextOwner.generation { + // Commit/flush held draft B before replacing callbacks with edit A. + // Account-generation revocation instead discards old view work below. + view.unmarkText() + publish() + } + // Native callbacks normally publish synchronously. Flush any storage-only + // transaction before an unrelated SwiftUI update can reapply an older binding. + if owner == nextOwner, (!requests.isEmpty || requestOverflow), !view.performingInputOperation { + publish() + } + apply(parent, in: view) + } + + private func refreshDuringHardwareResignation( + _ parent: ComposerEditor, in view: ComposerTextView, nextOwner: Owner + ) -> Bool { + guard let resignation = hardwareResignation else { return false } + let native = view.text ?? "" + func selectionCompatible() -> Bool { + guard let requested = parent.externalSelection, requested.text == native, + requested.selectionUTF16.location != NSNotFound, + requested.selectionUTF16.location >= 0, requested.selectionUTF16.length >= 0, + requested.selectionUTF16.location <= native.utf16.count, + requested.selectionUTF16.length <= native.utf16.count - requested.selectionUTF16.location + else { return true } + return requested.selectionUTF16 == view.selectedRange + } + func matches() -> Bool { + valid && self.view === view && view.window === resignation.window + && owner == resignation.owner && nextOwner == resignation.owner + && installationRevision == resignation.installation + && view.returnActionRevision == resignation.action + && view.hardwareReturnInProgress && !installing && !view.performingInputOperation + && !view.textStorage.editedMask.contains(.editedCharacters) + && view.markedTextRange == nil && !requestOverflow + && parent.focused && self.parent.focused && selectionCompatible() + && parent.text == native && view.text == native && reportedText == native + && lastPublished?.text == native && lastPublished?.markedRangeUTF16 == nil + && requests.allSatisfy { $0.beforeText == native && $0.resultingText != nil } + } + guard matches() else { return false } + let current = self.parent.isCurrentOwner() + guard current, matches() else { return false } + let incomingCurrent = parent.isCurrentOwner() + guard incomingCurrent, matches() else { + if valid, self.view === view, owner == resignation.owner, + installationRevision == resignation.installation, + view.returnActionRevision == resignation.action + { + view.cancelHardwareReturn() + } + return false + } + // UIKit can finish a prior character while resigning, and SwiftUI + // can synchronously render that exact published text. Refresh only + // its current callbacks/labels: a no-op render must neither cancel + // Return nor restore focus inside the outer native resignation. + // The old Binding may still read a cached pre-publication value. + // Unapplied, validated proposals remain owned by the normal ledger. + self.parent = parent + view.accessibilityIdentifier = parent.accessibilityIdentifier + view.accessibilityLabel = parent.placeholder + view.placeholderLabel.text = parent.placeholder + return true + } + + /// Install already-authoritative content without first publishing the + /// retired native buffer. Ordinary updates flush the previous owner + /// above; Send has already committed or retained that model content. + private func apply( + _ parent: ComposerEditor, in view: ComposerTextView, preservingPostSubmitInput: Bool = false, + restoringHardwareFocus: Bool = false, suppressSoftwareKeyboard: Bool? = nil, + reloadSoftwareKeyboard: Bool = false, restoringInteractionFocus: Bool = false + ) { + let admittedRevision = installationRevision + guard valid, self.view === view, parent.isCurrentOwner(), installationRevision == admittedRevision + else { return } + installationRevision = UUID() + let revision = installationRevision + let nextOwner = Owner(draft: parent.ownerID, generation: parent.accountGeneration) + let baseline = parent.text + let focusWindow = view.window + let priorInstalling = installing + var transaction = + preservingPostSubmitInput + ? SubmitInstallation(owner: nextOwner, baseline: baseline, revision: revision) : nil + submitInstallation = transaction + installing = true + defer { + installing = priorInstalling + submitInstallation = nil + // Focus cancellation/detachment ends presentation work, but + // exact input already received still belongs to this draft. + // finish rechecks model ownership and never submits/refocuses. + if let transaction { finish(transaction, in: view) } + } + let ownerChanged = owner != nextOwner + let contentChanged = view.text != baseline + // Retire composition only for actual authoritative replacement. + // Ordinary same-owner renders must preserve ongoing IME input. + if (ownerChanged || contentChanged), view.markedTextRange != nil { view.unmarkText() } + guard valid, installationRevision == revision, self.view === view else { return } + self.parent = parent + view.accessibilityIdentifier = parent.accessibilityIdentifier + view.accessibilityLabel = parent.placeholder + view.placeholderLabel.text = parent.placeholder + let previousSelection = view.selectedRange + let maximumLines = parent.isCompact ? 1 : 0 + if view.textContainer.maximumNumberOfLines != maximumLines { + view.textContainer.maximumNumberOfLines = maximumLines + view.textContainer.lineBreakMode = parent.isCompact ? .byTruncatingTail : .byWordWrapping + } + view.placeholderLabel.numberOfLines = parent.isCompact ? 1 : 0 + view.isCompact = parent.isCompact + view.updateKeyboardRestorationGesture() + if ownerChanged { + view.invalidateReturnCommands() + textPasteRevision = UUID() + owner = nextOwner + lastPublished = nil + } else if contentChanged { + view.cancelHardwareReturn() + } + if ownerChanged || contentChanged { + // Establish the authoritative baseline before the native + // setter can reenter an owned input method. + clearRequests() + reportedText = baseline + } + transaction?.acceptsNativeEdits = transaction?.replacementRevokesInput != true + if ownerChanged || contentChanged { + view.text = baseline + #if DEBUG + afterNativeTextAssignmentForProbe?() + #endif + // An actual native insertion owns its resulting selection. + if transaction?.receivedNativeEdit != true { + if !ownerChanged, let requested = parent.externalSelection, requested.text == baseline, + requested.selectionUTF16.location != NSNotFound, + requested.selectionUTF16.location >= 0, requested.selectionUTF16.length >= 0, + requested.selectionUTF16.location <= baseline.utf16.count, + requested.selectionUTF16.length <= baseline.utf16.count - requested.selectionUTF16.location + { + view.selectedRange = requested.selectionUTF16 + } else { + let end = + ownerChanged + ? baseline.utf16.count : min(previousSelection.location, baseline.utf16.count) + view.selectedRange = NSRange(location: end, length: 0) + } + } + } + view.placeholderLabel.isHidden = !view.text.isEmpty + let requestedPresentation: Bool? = + suppressSoftwareKeyboard + ?? (ownerChanged || (!restoringHardwareFocus && parent.focused && !view.isFirstResponder) + ? false : nil) + if transaction == nil, requestedPresentation == false, view.softwareKeyboardSuppressed { + // Ordinary replacement has already retired old composition and + // installed the new owner/baseline. Only the ensuing keyboard + // reload/focus callbacks may record exact input for that owner. + let keyboardTransaction = SubmitInstallation( + owner: nextOwner, baseline: baseline, revision: revision) + keyboardTransaction.acceptsNativeEdits = true + transaction = keyboardTransaction + submitInstallation = keyboardTransaction + } + let presentationActionRevision = view.returnActionRevision + let presentationChanged = requestedPresentation.map { view.setSoftwareKeyboardSuppressed($0) } ?? false + let reloadsPresentation = + reloadSoftwareKeyboard && !presentationChanged && view.isFirstResponder + && !view.softwareKeyboardSuppressed + if reloadsPresentation { view.reloadInputViews() } + if presentationChanged || reloadsPresentation { + let current = parent.isCurrentOwner() + guard current, valid, self.view === view, view.window === focusWindow, owner == nextOwner, + installationRevision == revision, view.returnActionRevision == presentationActionRevision, + transaction?.replacementRevokesInput != true + else { return } + } + if restoringHardwareFocus || restoringInteractionFocus { + // The successor's owner and baseline are already installed. + // Keep the native installation transaction open while focus + // resumes, so received input cannot target the retired draft. + if !parent.focused { parent.focused = true } + let current = parent.isCurrentOwner() + guard current, valid, self.view === view, installationRevision == revision, + focusWindow != nil, view.window === focusWindow, + transaction?.replacementRevokesInput != true, owner == nextOwner, + view.markedTextRange == nil + else { return } + } + if (restoringHardwareFocus || restoringInteractionFocus || parent.focused), !view.isFirstResponder { + if let window = focusWindow, view.window === window { + let previous = nativeFocusAcquisition + nativeFocusAcquisition = NativeFocusAcquisition( + owner: nextOwner, installation: revision, action: view.returnActionRevision, window: window) + defer { nativeFocusAcquisition = previous } + view.becomeFirstResponder() + } + } else if !restoringHardwareFocus, !restoringInteractionFocus, !parent.focused, view.isFirstResponder { + _ = view.resignFirstResponder() + } + #if DEBUG + traceFocus("apply.afterFocus") + #endif + if transaction == nil { schedulePublication() } + } + + private func finish(_ transaction: SubmitInstallation, in view: ComposerTextView) { + guard valid, self.view === view, owner == transaction.owner, + installationRevision == transaction.revision + else { return } + if transaction.replacementRevokesInput, let replacement = transaction.replacement { + // A new owner/account or changed authoritative value wins. + // Do not flush this transaction through either replacement. + clearRequests() + apply(replacement, in: view) + return + } + guard parent.isCurrentOwner(), installationRevision == transaction.revision else { return } + if transaction.receivedNativeEdit { + // A nested render of the unchanged committed baseline predates + // this publication. Its cached value cannot erase exact new input; + // the binding write supplies the next ordinary SwiftUI render. + publish(completedInput: true) + } else if let replacement = transaction.replacement { + apply(replacement, in: view) + } else { + schedulePublication() + } + } + + func recordNativeReplacement(before: String, range: NSRange, replacement: String) { + guard installing else { + recordReplacement(before: before, range: range, replacement: replacement) + return + } + guard valid, let transaction = submitInstallation, transaction.acceptsNativeEdits, + owner == transaction.owner, installationRevision == transaction.revision + else { return } + let request = DictationTextEdit(beforeText: before, rangeUTF16: range, replacement: replacement) + transaction.receivedNativeEdit = true + if requests.last == request, request.resultingText == transaction.expectedNativeText { return } + guard before == transaction.expectedNativeText, let result = request.resultingText else { + // Preserve the existing explicit unknown-edit boundary. No + // character diff is used to recover a dictation marker. + requests.removeAll(); requestBytes = 0; requestOverflow = true + return + } + transaction.expectedNativeText = result + append(request) + } + + func recordReplacement(before: String, range: NSRange, replacement: String) { + guard valid, !installing, !requestOverflow else { return } + let request = DictationTextEdit(beforeText: before, rangeUTF16: range, replacement: replacement) + append(request) + } + + private func append(_ request: DictationTextEdit) { + guard !requestOverflow, requests.last != request else { return } + let bytes = request.beforeText.utf8.count + request.replacement.utf8.count + guard requests.count < 64, bytes <= 8 * 1024 * 1024 - requestBytes else { + requests.removeAll(); requestBytes = 0; requestOverflow = true + return + } + requests.append(request); requestBytes += bytes + } + + func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) + -> Bool + { + if text == "\n", let view = textView as? ComposerTextView, view.continueList(range: range) { + return false + } + recordReplacement(before: textView.text ?? "", range: range, replacement: text) + #if DEBUG + observeInputOrder("proposed") + #endif + return true + } + + func textPasteConfigurationSupporting( + _ textPasteConfigurationSupporting: any UITextPasteConfigurationSupporting, + transform item: any UITextPasteItem + ) { + guard valid, !installing, let view, view.isEditable, view.markedTextRange == nil, let owner, + textPasteConfigurationSupporting as? ComposerTextView === view, + parent.isCurrentOwner(), parent.isCurrentPresentation(), + item.itemProvider.canLoadObject(ofClass: NSString.self) + else { item.setNoResult(); return } + let read = TextPasteRead(item: item, owner: owner, coordinator: self, view: view) + #if DEBUG + pendingTextPasteReads += 1 + #endif + // This editor accepts native plain strings, like UITextView's + // default with allowsEditingTextAttributes disabled. UIKit still + // combines the items and resolves the eventual target range. + _ = item.itemProvider.loadObject(ofClass: NSString.self) { object, _ in + let text = (object as? NSString).map { $0 as String } + Task { @MainActor in read.complete(text) } + } + } + + func textPasteConfigurationSupporting( + _ textPasteConfigurationSupporting: any UITextPasteConfigurationSupporting, + performPasteOf attributedString: NSAttributedString, to textRange: UITextRange + ) -> UITextRange { + let unchangedSelection = + (textPasteConfigurationSupporting as? UITextView)?.selectedTextRange ?? textRange + guard valid, !installing, let view, view.isEditable, view.markedTextRange == nil, + attributedString.length > 0, + textPasteConfigurationSupporting as? ComposerTextView === view, + parent.isCurrentOwner(), parent.isCurrentPresentation() + else { return unchangedSelection } + var owned = true + attributedString.enumerateAttribute( + Self.textPasteOwnerKey, in: NSRange(location: 0, length: attributedString.length) + ) { value, _, _ in + guard let marker = value as? TextPasteOwner, + marker.value == owner, marker.revision == textPasteRevision + else { owned = false; return } + } + guard owned else { return unchangedSelection } + // UIKit has already decoded/combined the providers and resolved + // the actual insertion range. This plain-text editor preserves + // that native range without treating pasted LF as Return. + view.performTextPaste(attributedString.string, in: textRange) + return view.selectedTextRange ?? unchangedSelection + } + + private func clearRequests() { + requests.removeAll(); requestBytes = 0; requestOverflow = false + } + + private func exactChanges(endingAt text: String) -> [DictationTextEdit]? { + if !requestOverflow { + // Prefer the innermost verified transitions when an input method + // calls another public input method or its delegate recursively. + var target = text + var reversed: [DictationTextEdit] = [] + for request in requests.reversed() where request.resultingText == target { + reversed.append(request); target = request.beforeText + } + if target == reportedText, !reversed.isEmpty { return Array(reversed.reversed()) } + if requests.isEmpty, text == reportedText { return [] } + } + return nil + } + + private func unknownChange() -> [DictationTextEdit] { + // A storage notification is an aggregate, not edit intent. Never + // infer a marker from a diff or an attribute-expanded storage span. + [ + DictationTextEdit( + beforeText: reportedText, + rangeUTF16: NSRange(location: NSNotFound, length: 0), replacement: "") + ] + } + + func textViewDidChange(_ textView: UITextView) { + #if DEBUG + observeInputOrder("didChange") + #endif + publish(completedInput: true) + } + func textViewDidChangeSelection(_ textView: UITextView) { + #if DEBUG + observeInputOrder("selection") + #endif + publish() + } + + private func admitsNativeFocusCallback(_ textView: UITextView, firstResponder: Bool) -> Bool { + let revision = installationRevision + let destinationOwner = owner + let actionRevision = view?.returnActionRevision + let window = textView.window + func matches() -> Bool { + valid && !installing && view === textView && window != nil && textView.window === window + && owner == destinationOwner && parent.ownerID == destinationOwner?.draft + && parent.accountGeneration == destinationOwner?.generation + && installationRevision == revision && view?.returnActionRevision == actionRevision + && textView.isFirstResponder == firstResponder + } + return matches() && parent.isCurrentOwner() && matches() + && (!firstResponder || (parent.isCurrentPresentation() && matches())) + } + + func textViewShouldBeginEditing(_ textView: UITextView) -> Bool { + let revision = installationRevision + let destinationOwner = owner + let actionRevision = view?.returnActionRevision + let window = textView.window + func matches() -> Bool { + guard valid, view === textView, let window, textView.window === window, + owner == destinationOwner, parent.ownerID == destinationOwner?.draft, + parent.accountGeneration == destinationOwner?.generation, + installationRevision == revision, view?.returnActionRevision == actionRevision, + submitInstallation?.replacementRevokesInput != true + else { return false } + if parent.focused { return true } + // An admitted native touch may write SwiftUI's focus binding + // before its cached getter sees that write. Only the exact + // synchronous acquisition transaction may bridge that render. + guard let acquisition = nativeFocusAcquisition else { return false } + return acquisition.owner == destinationOwner && acquisition.installation == revision + && acquisition.action == actionRevision && acquisition.window === window + } + let admitted = + matches() && parent.isCurrentOwner() && matches() + && parent.isCurrentPresentation() && matches() + #if DEBUG + traceFocus("native.shouldBegin=\(admitted)") + #endif + return admitted + } + + func textViewDidBeginEditing(_ textView: UITextView) { + #if DEBUG + traceFocus("native.didBegin.before") + #endif + // UIKit can deliver a queued Begin after navigation has already + // resigned this exact view. Only its current native focus may + // publish editing intent and reopen keyboard presentation. + if admitsNativeFocusCallback(textView, firstResponder: true) { parent.focused = true } + #if DEBUG + traceFocus("native.didBegin.after") + #endif + schedulePublication() + } + func textViewDidEndEditing(_ textView: UITextView) { + #if DEBUG + traceFocus("native.didEnd.before") + #endif + if (textView as? ComposerTextView)?.hardwareReturnInProgress != true, + admitsNativeFocusCallback(textView, firstResponder: false) + { + parent.focused = false + } + #if DEBUG + traceFocus("native.didEnd.after") + #endif + schedulePublication() + } + + private func schedulePublication() { + guard valid, !publicationScheduled else { return } + publicationScheduled = true + Task { @MainActor [weak self] in + guard let self else { return } + self.publicationScheduled = false + self.publish() + } + } + + fileprivate func handlesImagePaste(_ providers: [NSItemProvider], in view: ComposerTextView) -> Bool { + let destinationOwner = owner, revision = textPasteRevision, installation = installationRevision + let window = view.window + guard let action = parent.onPasteImages else { return false } + if action(providers) { return true } + // A declined route may fall through only in the initiating + // editor. A callback can retire or replace it synchronously. + func matches() -> Bool { + valid && !installing && self.view === view && view.delegate === self + && owner == destinationOwner && textPasteRevision == revision + && installationRevision == installation && view.window === window + && view.isEditable && view.markedTextRange == nil + } + guard matches(), parent.isCurrentOwner(), matches(), parent.isCurrentPresentation(), matches() + else { return true } + return false + } + + func publish(completedInput: Bool = false) { + #if DEBUG + observeInputOrder(completedInput ? "complete" : "observe") + #endif + guard valid, !installing, let view, !view.performingInputOperation, + !view.textStorage.editedMask.contains(.editedCharacters) + else { + #if DEBUG + observeInputOrder("publish.guard-return") + #endif + return + } + let text = view.text ?? "" + let selection = view.selectedRange + let marked = view.markedTextRange.map { + NSRange( + location: view.offset(from: view.beginningOfDocument, to: $0.start), + length: view.offset(from: $0.start, to: $0.end)) + } + let changes: [DictationTextEdit] + if text == reportedText, !requests.isEmpty || requestOverflow { + if completedInput { + // An owned input operation has returned, or UIKit delivered + // didChange. A same-text exact replacement still carries edit + // intent; a proposal that never changed storage is rejected. + changes = requestOverflow ? unknownChange() : exactChanges(endingAt: text) ?? [] + clearRequests() + } else { + // UIKit may deliver a selection callback after shouldChange + // but before applying its proposed replacement. Publish the + // caret/composition observation without consuming that proposal. + changes = [] + } + } else { + changes = exactChanges(endingAt: text) ?? unknownChange() + clearRequests() + } + reportedText = text + #if DEBUG + observeInputOrder( + "publish.resolved; changes=\(changes.count); unknown=\(changes.filter { $0.resultingText == nil }.count)" + ) + #endif + let state = DictationEditorState(text: text, selectionUTF16: selection, markedRangeUTF16: marked) + view.placeholderLabel.isHidden = !text.isEmpty + guard state != lastPublished || !changes.isEmpty else { + #if DEBUG + observeInputOrder("publish.unchanged") + #endif + return + } + lastPublished = state + // A binding mutation can synchronously trigger a SwiftUI owner update. + // Keep this event attached to the callbacks that owned its native text. + let destination = parent + #if DEBUG + observeInputOrder("publish.before-binding; destinationBinding=\(destination.text.utf16.count)") + #endif + if destination.text != text { destination.text = text } + #if DEBUG + observeInputOrder("publish.after-binding; destinationBinding=\(destination.text.utf16.count)") + #endif + destination.onChange(state, changes) + #if DEBUG + observeInputOrder("publish.after-callback; destinationBinding=\(destination.text.utf16.count)") + #endif + view.invalidateIntrinsicContentSize() + } + + /// A measured native resignation boundary, not a delayed callback + /// or an inferred relationship between press and text-input events. + fileprivate func hardwareReturn(shifted: Bool) { + guard valid, !installing, let view, let window = view.window, + view.isFirstResponder, view.markedTextRange == nil, !view.performingInputOperation + else { return } + let destinationOwner = owner + let revision = installationRevision + let actionRevision = view.returnActionRevision + func admitted() -> Bool { + guard valid, self.view === view, view.window === window, owner == destinationOwner, + installationRevision == revision, view.returnActionRevision == actionRevision, + !installing, !view.performingInputOperation, view.markedTextRange == nil, + !view.textStorage.editedMask.contains(.editedCharacters) + else { return false } + let current = parent.isCurrentOwner() + return current && valid && self.view === view && view.window === window + && owner == destinationOwner && installationRevision == revision + && view.returnActionRevision == actionRevision && !installing + && view.markedTextRange == nil && !view.performingInputOperation + && !view.textStorage.editedMask.contains(.editedCharacters) + } + guard admitted() else { return } + defer { + // A refused/cancelled barrier must not leave a stale focus + // binding. A replacement installation owns its own focus. + if valid, self.view === view, owner == destinationOwner, installationRevision == revision, + parent.isCurrentOwner(), installationRevision == revision, + parent.focused != view.isFirstResponder + { + parent.focused = view.isFirstResponder + } + } + #if DEBUG + observeInputOrder("barrier.begin") + #endif + let resigned: Bool = { + hardwareResignation = HardwareResignation( + owner: destinationOwner, installation: revision, action: actionRevision, window: window) + defer { hardwareResignation = nil } + return view.resignForHardwareReturn() + }() + #if DEBUG + observeInputOrder("barrier.end; resigned=\(resigned)") + #endif + if installationRevision != revision { + // An authoritative replacement can install during UIKit's + // resignation callbacks. The outer native resignation may + // finish afterward; only that replacement can reclaim focus. + reconcileReplacementFocus(in: view, window: window, generation: destinationOwner?.generation) + return + } + guard resigned, !view.isFirstResponder, admitted() else { return } + publish(completedInput: true) + guard !view.isFirstResponder, admitted(), reportedText == view.text else { return } + if shifted { + // Commit the newline before restoring the input target. + // Later received characters therefore follow this exact edit. + view.insertText("\n") + guard !view.isFirstResponder, admitted() else { return } + apply(parent, in: view, preservingPostSubmitInput: true, restoringHardwareFocus: true) + } else { + submit(afterHardwareResignation: true) + } + } + + private func reconcileReplacementFocus(in view: ComposerTextView, window: UIWindow, generation: UUID?) { + guard let replacementOwner = owner, replacementOwner.generation == generation else { return } + let revision = installationRevision + let actionRevision = view.returnActionRevision + func matches() -> Bool { + valid && self.view === view && view.window === window && owner == replacementOwner + && installationRevision == revision && view.returnActionRevision == actionRevision + && parent.ownerID == replacementOwner.draft + && parent.accountGeneration == replacementOwner.generation && parent.focused + && !view.isFirstResponder && !installing && !view.performingInputOperation + && view.markedTextRange == nil && !view.textStorage.editedMask.contains(.editedCharacters) + } + func admitted() -> Bool { + guard matches() else { return false } + let current = parent.isCurrentOwner() + return current && matches() + } + guard admitted() else { return } + // Publish any exact input received by the replacement before + // touching focus. A stale binding must not erase native text. + publish() + guard admitted(), parent.text == view.text, reportedText == view.text, matches() else { return } + // The existing transaction captures input delivered by focus + // restoration under the replacement's callbacks and baseline. + apply(parent, in: view, preservingPostSubmitInput: true) + } + + func submit() { submit(afterHardwareResignation: false) } + + private func submit(afterHardwareResignation: Bool) { + #if DEBUG + observeInputOrder("submit.entry") + #endif + guard valid, let view, view.markedTextRange == nil else { return } + let destination = parent + let destinationOwner = owner + let actionRevision = view.returnActionRevision + let publicationRevision = installationRevision + let destinationWindow = view.window + publish() + let stillCurrent = destination.isCurrentOwner() + guard stillCurrent, valid, owner == destinationOwner, self.view === view, + view.window === destinationWindow, installationRevision == publicationRevision, + view.returnActionRevision == actionRevision, + !installing, (afterHardwareResignation ? !view.isFirstResponder : view.isFirstResponder), + view.markedTextRange == nil, !view.performingInputOperation + else { return } + #if DEBUG + observeInputOrder("submit.after-publish; destinationBinding=\(destination.text.utf16.count)") + #endif + let submittedInstallation = installationRevision + destination.onSubmit() + #if DEBUG + observeInputOrder("submit.after-callback; destinationBinding=\(destination.text.utf16.count)") + #endif + // Core may synchronously detach the sent draft while SwiftUI's + // view update is still pending. Install its authoritative successor + // now, so a queued native insertion cannot republish the sent text + // or reach the retired draft's callbacks. + guard valid, owner == destinationOwner, self.view === view, view.window === destinationWindow, + installationRevision == submittedInstallation, view.returnActionRevision == actionRevision, + !installing, !view.performingInputOperation, view.markedTextRange == nil + else { return } + let next: ComposerEditor + if let refresh = destination.refreshAfterSubmit { + guard let refreshed = refresh() else { + if valid, owner == destinationOwner, self.view === view, + installationRevision == submittedInstallation + { + view.cancelHardwareReturn() + } + return + } + next = refreshed + } else { + next = parent + } + guard valid, owner == destinationOwner, self.view === view, view.window === destinationWindow, + installationRevision == submittedInstallation, view.returnActionRevision == actionRevision + else { return } + guard next.accountGeneration == destinationOwner?.generation else { + view.cancelHardwareReturn() + return + } + // Accepted/reserved Send requests dismissal through its focus + // binding. Retain hardware input with a custom empty input view; + // failed Send and Shift do not introduce that presentation. + let dismissSoftwareKeyboard = afterHardwareResignation && !next.focused + apply( + next, in: view, preservingPostSubmitInput: true, + restoringHardwareFocus: afterHardwareResignation, + suppressSoftwareKeyboard: dismissSoftwareKeyboard ? true : nil) + } + + @discardableResult fileprivate func showSoftwareKeyboard(in view: ComposerTextView) -> Bool { + #if DEBUG + traceFocus("interaction.begin") + #endif + view.cancelHardwareReturn() + var revision = installationRevision + let destinationOwner = owner + let actionRevision = view.returnActionRevision + let window = view.window + func matches() -> Bool { + valid && self.view === view && view.window === window && window != nil + && owner == destinationOwner && installationRevision == revision + && view.returnActionRevision == actionRevision + && !installing && !view.performingInputOperation && !view.hardwareReturnInProgress + && view.markedTextRange == nil && !view.textStorage.editedMask.contains(.editedCharacters) + } + func refreshAfterInteraction() -> Bool { + // Rendering an admitted focus/presentation change may + // synchronously refresh this same editor. Its new callbacks + // can continue only with the exact installed owner/baseline. + let nextRevision = installationRevision + func sameInput() -> Bool { + valid && self.view === view && view.window === window && window != nil + && owner == destinationOwner && parent.ownerID == destinationOwner?.draft + && parent.accountGeneration == destinationOwner?.generation + && installationRevision == nextRevision && view.returnActionRevision == actionRevision + && !installing && !view.performingInputOperation && !view.hardwareReturnInProgress + && view.markedTextRange == nil && !view.textStorage.editedMask.contains(.editedCharacters) + && parent.text == view.text && reportedText == view.text + } + guard sameInput(), parent.isCurrentOwner(), sameInput(), parent.isCurrentPresentation(), sameInput() + else { return false } + revision = nextRevision + return true + } + guard matches(), parent.isCurrentOwner(), matches(), parent.isCurrentPresentation(), matches() + else { return false } + publish() + guard matches(), parent.isCurrentOwner(), matches(), parent.isCurrentPresentation(), matches(), + parent.text == view.text, + reportedText == view.text + else { return false } + // A keyboard hide can retain the native first responder while + // SwiftUI collapses this existing-chat editor. An explicit tap + // begins editing again even when custom hardware suppression + // was never installed. Presentation admission can revoke it. + let reloadsPresentation = + view.isCompact && !view.softwareKeyboardSuppressed && view.isFirstResponder + let interactionAdmitted = parent.onEditingInteraction() + #if DEBUG + traceFocus("interaction.admitted=\(interactionAdmitted)") + #endif + guard interactionAdmitted, refreshAfterInteraction() else { return false } + if !parent.focused { parent.focused = true } + #if DEBUG + traceFocus("interaction.focusWritten") + #endif + guard refreshAfterInteraction() else { return false } + // Keep reload callbacks in the same exact-input transaction used + // for successor focus, without committing marked text. + apply( + parent, in: view, preservingPostSubmitInput: true, suppressSoftwareKeyboard: false, + reloadSoftwareKeyboard: reloadsPresentation, restoringInteractionFocus: true) + return view.isFirstResponder && refreshAfterInteraction() + } + } + } + + @MainActor final class ComposerTextView: UITextView { + let placeholderLabel = UILabel() + var isCompact = false { + didSet { + if isCompact != oldValue { + updateKeyboardRestorationGesture() + updateVerticalAlignment() + setNeedsLayout() + #if DEBUG + (delegate as? ComposerEditor.Coordinator)?.traceFocus("compact.changed") + #endif + } + } + } + private var placeholderTopConstraint: NSLayoutConstraint? + var changed: (() -> Void)? + var submit: (() -> Void)? + var willReplace: ((String, NSRange, String) -> Void)? + private var operationDepth = 0 + private var pastingText = false + var performingInputOperation: Bool { operationDepth > 0 } + private var returnCommandIdentity = UUID() + private(set) var hardwareReturnInProgress = false + private var expectedHardwareResignation = false + private(set) var returnActionRevision = UUID() + + private lazy var suppressedInputView: UIView = { + let view = UIView(frame: .zero) + view.autoresizingMask = [.flexibleWidth] + view.isUserInteractionEnabled = false + view.accessibilityElementsHidden = true + return view + }() + private(set) var softwareKeyboardSuppressed = false + + private final class KeyboardRestorationDelegate: NSObject, UIGestureRecognizerDelegate { + weak var view: ComposerTextView? + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard let view else { return false } + #if DEBUG + (view.delegate as? ComposerEditor.Coordinator)?.traceFocus("tap.shouldBegin") + #endif + guard gestureRecognizer === view.keyboardRestorationTap, view.needsEditingInteraction else { + return false + } + // Admit this real touch before UIKit requests native focus. A + // retained NavigationStack can otherwise restore its old text + // responder without a touch even after the app dismissed it. + return view.showSoftwareKeyboardForInteraction() + } + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + guard let view else { return false } + return gestureRecognizer === view.keyboardRestorationTap + || otherGestureRecognizer === view.keyboardRestorationTap + } + } + private let keyboardRestorationDelegate = KeyboardRestorationDelegate() + private lazy var keyboardRestorationTap = UITapGestureRecognizer( + target: self, action: #selector(restoreKeyboardFromTap(_:))) + + @objc private func restoreKeyboardFromTap(_ recognizer: UITapGestureRecognizer) { + guard recognizer === keyboardRestorationTap, recognizer.view === self, + recognizer.state == .ended + else { return } + #if DEBUG + (delegate as? ComposerEditor.Coordinator)?.traceFocus("tap.ended") + #endif + } + + fileprivate func removeKeyboardRestorationGesture() { + keyboardRestorationTap.isEnabled = false + keyboardRestorationTap.delegate = nil + keyboardRestorationTap.removeTarget(self, action: #selector(restoreKeyboardFromTap(_:))) + removeGestureRecognizer(keyboardRestorationTap) + keyboardRestorationDelegate.view = nil + } + + @discardableResult fileprivate func setSoftwareKeyboardSuppressed(_ suppressed: Bool) -> Bool { + guard softwareKeyboardSuppressed != suppressed else { return false } + softwareKeyboardSuppressed = suppressed + updateKeyboardRestorationGesture() + inputView = suppressed ? suppressedInputView : nil + if isFirstResponder { reloadInputViews() } + return true + } + + fileprivate var needsEditingInteraction: Bool { + softwareKeyboardSuppressed || isCompact + || (delegate as? ComposerEditor.Coordinator)?.parent.focused == false + } + + fileprivate func updateKeyboardRestorationGesture() { + keyboardRestorationTap.isEnabled = needsEditingInteraction + } + + @discardableResult func showSoftwareKeyboardForInteraction() -> Bool { + guard needsEditingInteraction else { return false } + return (delegate as? ComposerEditor.Coordinator)?.showSoftwareKeyboard(in: self) ?? false + } + + override func accessibilityActivate() -> Bool { + showSoftwareKeyboardForInteraction() + return super.accessibilityActivate() + } + + func cancelHardwareReturn() { + #if DEBUG + observeInputOperation("command.cancel; hadAction=\(hardwareReturnInProgress)") + #endif + returnActionRevision = UUID() + } + + func invalidateReturnCommands() { + cancelHardwareReturn() + returnCommandIdentity = UUID() + } + + private var acceptsReturnCommand: Bool { + guard let coordinator = delegate as? ComposerEditor.Coordinator, coordinator.valid, !coordinator.installing + else { + return false + } + return !hardwareReturnInProgress && submit != nil && isEditable && isFirstResponder + && markedTextRange == nil + && !performingInputOperation + && !textStorage.editedMask.contains(.editedCharacters) + } + + override var keyCommands: [UIKeyCommand]? { + let inherited = super.keyCommands ?? [] + guard acceptsReturnCommand else { return inherited } + let commands = ["\r", "\u{3}"].flatMap { input in + [UIKeyModifierFlags(), .shift].map { modifiers in + let command = UIKeyCommand( + title: "", action: #selector(handleComposerReturn(_:)), input: input, + modifierFlags: modifiers, propertyList: returnCommandIdentity.uuidString) + // Keep Return and keypad Enter on the same ownership fence. + // Software text input never invokes this hardware command action. + command.wantsPriorityOverSystemBehavior = true + return command + } + } + return commands + inherited + } + + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + if action == #selector(handleComposerReturn(_:)) { + guard acceptsReturnCommand else { return false } + guard let sender else { return true } + guard let command = sender as? UIKeyCommand else { return false } + return ownsReturnCommand(command) + } + if action == #selector(paste(_:)), admitsImagePaste, UIPasteboard.general.hasImages { return true } + return super.canPerformAction(action, withSender: sender) + } + + private var admitsImagePaste: Bool { + guard let coordinator = delegate as? ComposerEditor.Coordinator, + coordinator.valid, !coordinator.installing, isEditable, markedTextRange == nil, + coordinator.parent.onPasteImages != nil + else { return false } + return coordinator.parent.isCurrentOwner() && coordinator.parent.isCurrentPresentation() + } + + private func ownsReturnCommand(_ command: UIKeyCommand) -> Bool { + command.action == #selector(handleComposerReturn(_:)) + && (command.input == "\r" || command.input == "\u{3}") + && (command.propertyList as? String) == returnCommandIdentity.uuidString + && (command.modifierFlags.isEmpty || command.modifierFlags == .shift) + } + + @objc func handleComposerReturn(_ sender: Any?) { + let command = sender as? UIKeyCommand + #if DEBUG + let kind = + command.map { + $0.modifierFlags == .shift ? "newline" : $0.modifierFlags.isEmpty ? "submit" : "invalid" + } ?? "invalid" + observeInputOperation( + "command.entry; kind=\(kind); modifierMask=\(command?.modifierFlags.rawValue ?? 0); " + + "ownerAccepted=\(command.map { ownsReturnCommand($0) } ?? false); eligible=\(acceptsReturnCommand)" + ) + #endif + guard acceptsReturnCommand, let command, ownsReturnCommand(command) else { + return + } + guard let coordinator = delegate as? ComposerEditor.Coordinator else { return } + hardwareReturnInProgress = true + defer { + hardwareReturnInProgress = false + #if DEBUG + observeInputOperation("command.exit") + #endif + } + coordinator.hardwareReturn(shifted: command.modifierFlags == .shift) + } + + fileprivate func resignForHardwareReturn() -> Bool { + guard hardwareReturnInProgress, !expectedHardwareResignation else { return false } + expectedHardwareResignation = true + defer { expectedHardwareResignation = false } + return resignFirstResponder() + } + + override func paste(_ sender: Any?) { + cancelHardwareReturn() + if admitsImagePaste { + // Only the explicit native paste action reads providers. Menu + // validation above checks type availability without reading bytes. + let images = UIPasteboard.general.itemProviders.filter { + $0.hasItemConformingToTypeIdentifier(UTType.image.identifier) + } + if !images.isEmpty, admitsImagePaste, + let coordinator = delegate as? ComposerEditor.Coordinator + { + if coordinator.handlesImagePaste(images, in: self) { return } + } + } + pastingText = true + defer { pastingText = false } + super.paste(sender) + } + + fileprivate func performTextPaste(_ text: String, in range: UITextRange) { + let previous = pastingText + pastingText = true + defer { pastingText = previous } + replace(range, withText: text) + } + override func resignFirstResponder() -> Bool { + #if DEBUG + (delegate as? ComposerEditor.Coordinator)?.traceFocus("native.resign") + #endif + let expected = expectedHardwareResignation + expectedHardwareResignation = false + let revision = returnActionRevision + if !expected { cancelHardwareReturn() } + #if DEBUG + beforeResignFirstResponderForProbe?() + if refuseResignationForProbe { return false } + #endif + if expected, (returnActionRevision != revision || markedTextRange != nil) { return false } + return super.resignFirstResponder() + } + #if DEBUG + var beforeResignFirstResponderForProbe: (() -> Void)? + var beforeBecomeFirstResponderForProbe: (() -> Void)? + var refuseResignationForProbe = false + override func becomeFirstResponder() -> Bool { + (delegate as? ComposerEditor.Coordinator)?.traceFocus("native.become") + beforeBecomeFirstResponderForProbe?() + return super.becomeFirstResponder() + } + var beforeReloadInputViewsForProbe: (() -> Void)? + override func reloadInputViews() { + beforeReloadInputViewsForProbe?() + super.reloadInputViews() + } + #endif + #if DEBUG + var inputOrderObserver: ((String) -> Void)? + private func observeInputOperation(_ event: String, inputLength: Int = 0) { + inputOrderObserver?("\(event); inputLength=\(inputLength); depth=\(operationDepth)") + } + private func observePresses(_ phase: String, _ presses: Set) { + guard let inputOrderObserver else { return } + // Never record character keys or their characters/scalars. Only + // the two submit/newline HID usages and numeric modifier masks. + let keys = presses.compactMap(\.key) + let returns = keys.filter { $0.keyCode == .keyboardReturnOrEnter || $0.keyCode == .keypadEnter } + let codes = returns.prefix(16).map { "\($0.keyCode.rawValue):\($0.modifierFlags.rawValue)" } + .sorted().joined(separator: "|") + let modifiers = Set(keys.map { $0.modifierFlags.rawValue }).sorted().prefix(16) + .map { String($0) }.joined(separator: "|") + inputOrderObserver( + "press.\(phase); count=\(presses.count); returnCount=\(returns.count); " + + "returnHID=\(codes); modifierMasks=\(modifiers); depth=\(operationDepth)") + } + override func pressesEnded(_ presses: Set, with event: UIPressesEvent?) { + observePresses("ended", presses) + super.pressesEnded(presses, with: event) + } + override func pressesCancelled(_ presses: Set, with event: UIPressesEvent?) { + observePresses("cancelled", presses) + super.pressesCancelled(presses, with: event) + } + #endif + + override init(frame: CGRect, textContainer: NSTextContainer?) { + super.init(frame: frame, textContainer: textContainer) + // Observe an explicit tap alongside UIKit's text-selection gestures. + // Do not cancel/delay their touches or replace their delegates. + keyboardRestorationDelegate.view = self + keyboardRestorationTap.delegate = keyboardRestorationDelegate + keyboardRestorationTap.cancelsTouchesInView = false + keyboardRestorationTap.delaysTouchesBegan = false + keyboardRestorationTap.delaysTouchesEnded = false + keyboardRestorationTap.isEnabled = false + addGestureRecognizer(keyboardRestorationTap) + placeholderLabel.font = .preferredFont(forTextStyle: .body) + placeholderLabel.adjustsFontForContentSizeCategory = true + placeholderLabel.textColor = .placeholderText + placeholderLabel.isAccessibilityElement = false + placeholderLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(placeholderLabel) + let placeholderTop = placeholderLabel.topAnchor.constraint(equalTo: topAnchor) + placeholderTopConstraint = placeholderTop + NSLayoutConstraint.activate([ + placeholderLabel.leadingAnchor.constraint( + equalTo: leadingAnchor, constant: ComposerEditorMetrics.horizontalInset), + placeholderTop, + placeholderLabel.trailingAnchor.constraint( + lessThanOrEqualTo: trailingAnchor, constant: -ComposerEditorMetrics.horizontalInset), + ]) + } + required init?(coder: NSCoder) { fatalError("init(coder:) is unavailable") } + + override func layoutSubviews() { + updateVerticalAlignment() + super.layoutSubviews() + } + + private func updateVerticalAlignment() { + // The collapsed editor keeps its 44-point tap surface, with its + // single native line and placeholder centered beside the controls. + // Expanded drafts keep UITextView's normal top-aligned layout. + let lineHeight = font?.lineHeight ?? placeholderLabel.font.lineHeight + let inset = ComposerEditorMetrics.topInset( + height: bounds.height, lineHeight: lineHeight, compact: isCompact) + let insets = UIEdgeInsets( + top: inset, left: ComposerEditorMetrics.horizontalInset, + bottom: inset, right: ComposerEditorMetrics.horizontalInset) + if textContainerInset != insets { textContainerInset = insets } + placeholderTopConstraint?.constant = ComposerEditorMetrics.topInset( + height: bounds.height, lineHeight: placeholderLabel.intrinsicContentSize.height, compact: isCompact) + } + + override func setMarkedText(_ markedText: String?, selectedRange: NSRange) { + cancelHardwareReturn() + #if DEBUG + observeInputOperation("mark.begin", inputLength: markedText?.utf16.count ?? 0) + #endif + if let markedText { captureReplacement(range: markedReplacementRange, text: markedText) } + operationDepth += 1 + super.setMarkedText(markedText, selectedRange: selectedRange) + operationDepth -= 1 + #if DEBUG + observeInputOperation("mark.end", inputLength: markedText?.utf16.count ?? 0) + #endif + if operationDepth == 0 { changed?() } + } + override func unmarkText() { + #if DEBUG + observeInputOperation("unmark.begin") + #endif + operationDepth += 1 + super.unmarkText() + operationDepth -= 1 + #if DEBUG + observeInputOperation("unmark.end") + #endif + if operationDepth == 0 { changed?() } + } + override func replace(_ range: UITextRange, withText text: String) { + #if DEBUG + observeInputOperation("replace.begin", inputLength: text.utf16.count) + #endif + captureReplacement( + range: NSRange( + location: offset(from: beginningOfDocument, to: range.start), + length: offset(from: range.start, to: range.end)), text: text) + operationDepth += 1 + super.replace(range, withText: text) + operationDepth -= 1 + #if DEBUG + observeInputOperation("replace.end", inputLength: text.utf16.count) + #endif + if operationDepth == 0 { changed?() } + } + override func insertText(_ text: String) { + #if DEBUG + observeInputOperation("insert.begin", inputLength: text.utf16.count) + #endif + if text == "\n", continueList(range: selectedRange) { return } + captureReplacement(range: markedReplacementRange, text: text) + operationDepth += 1 + super.insertText(text) + operationDepth -= 1 + #if DEBUG + observeInputOperation("insert.end", inputLength: text.utf16.count) + #endif + if operationDepth == 0 { changed?() } + } + + @discardableResult fileprivate func continueList(range: NSRange) -> Bool { + guard markedTextRange == nil, !performingInputOperation, !pastingText, isEditable, + let coordinator = delegate as? ComposerEditor.Coordinator, coordinator.valid, !coordinator.installing, + coordinator.parent.isCurrentOwner(), coordinator.parent.isCurrentPresentation(), + let edit = ComposerListContinuation.edit(text: text ?? "", selectionUTF16: range), + let start = position(from: beginningOfDocument, offset: edit.rangeUTF16.location), + let end = position(from: start, offset: edit.rangeUTF16.length), + let nativeRange = textRange(from: start, to: end) + else { return false } + let scrollToEnd = NSMaxRange(range) == (text ?? "").utf16.count + // replace(_:withText:) records the actual transaction through + // recordNativeReplacement, retaining UIKit's ordinary undo behavior. + operationDepth += 1 + replace(nativeRange, withText: edit.replacement) + selectedRange = edit.selectionUTF16 + operationDepth -= 1 + if scrollToEnd { scrollRangeToVisible(edit.selectionUTF16) } + changed?() + return true + } + override func deleteBackward() { + #if DEBUG + observeInputOperation("delete.begin") + #endif + var range = selectedRange + if range.length == 0, range.location > 0, range.location <= (text ?? "").utf16.count { + range = ((text ?? "") as NSString).rangeOfComposedCharacterSequence(at: range.location - 1) + } + captureReplacement(range: range, text: "") + operationDepth += 1 + super.deleteBackward() + operationDepth -= 1 + #if DEBUG + observeInputOperation("delete.end") + #endif + if operationDepth == 0 { changed?() } + } + + private var markedReplacementRange: NSRange { + guard let markedTextRange else { return selectedRange } + return NSRange( + location: offset(from: beginningOfDocument, to: markedTextRange.start), + length: offset(from: markedTextRange.start, to: markedTextRange.end)) + } + private func captureReplacement(range: NSRange, text replacement: String) { + willReplace?(text ?? "", range, replacement) + } + + override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { + #if DEBUG + observePresses("began", presses) + #endif + // Let UIKit sequence text input and key-command dispatch. Submitting + // from raw press delivery can overtake an earlier text insertion. + super.pressesBegan(presses, with: event) + } + } + + /// NSTextStorage is not actor-isolated. This observer moves only a notification + /// onto the main actor; all text, ranges and UI state stay in their UIKit owner. + final class ComposerStorageObserver: NSObject, NSTextStorageDelegate { + let processed: @Sendable () -> Void + init(processed: @escaping @Sendable () -> Void) { self.processed = processed } + func textStorage( + _ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorage.EditActions, + range editedRange: NSRange, changeInLength delta: Int + ) { + if editedMask.contains(.editedCharacters) { processed() } + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Composer/ComposerKeyboardFixtureView.swift b/apps/maple-ios/Sources/MapleAppUI/Composer/ComposerKeyboardFixtureView.swift new file mode 100644 index 000000000..a0590b66f --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Composer/ComposerKeyboardFixtureView.swift @@ -0,0 +1,554 @@ +#if os(iOS) && DEBUG + import SwiftUI + import UIKit + import MapleAppCore + import UniformTypeIdentifiers + + // Records callback delivery without mutating SwiftUI State while UIKit is + // synchronously finalizing an old owner from updateUIView. The test explicitly + // reads this content-free ledger after the view update has returned. + @MainActor private final class ComposerFixtureOwnerEvents { + var latest: [UUID: Bool] = [:] + var counts: [UUID: Int] = [:] + func record(owner: UUID, marked: Bool) { + latest[owner] = marked + counts[owner, default: 0] += 1 + } + } + + @MainActor private final class ComposerFixtureInputEvents { + var entries: [String] = [] + private(set) var dropped = 0 + func reset() { + entries.removeAll() + dropped = 0 + } + func record(_ event: String) { + guard entries.count < 256 else { + if dropped < Int.max { dropped += 1 } + return + } + entries.append("\(entries.count + 1): \(event.prefix(1024))") + } + var snapshot: String { + "Composer input diagnostics v1; recorded=\(entries.count); dropped=\(dropped)\n" + + entries.joined(separator: "\n") + } + } + + /// Compares the documented SwiftUI hardware-key path before adopting any + /// shipping UIKit composer integration. All content is synthetic and in memory. + struct ComposerKeyboardFixtureView: View { + @State private var text = "" + @State private var sends = 0 + @State private var marked = false + @State private var nativeMarkStatus = "Native marked 0" + @State private var nativeText = "" + @State private var keyEvents = 0 + @State private var returnEvents = 0 + @State private var ownerID = UUID() + @State private var nativeFocused = true + @State private var editSummary = "Edits 0; invalid 0" + @State private var editCount = 0 + @State private var invalidEdits = 0 + @State private var previousOwner: UUID? + @State private var ownerEvents = ComposerFixtureOwnerEvents() + @State private var previousOwnerStatus = "No committed owner" + @State private var insertionMarker: DictationInsertionAnchor? + @State private var transactionSummary = "" + @State private var reentrantStatus = "Not checked" + @State private var orderingStatus = "Not checked" + @State private var inputEvents = ComposerFixtureInputEvents() + @State private var inputOrderStatus = "Not recording" + @State private var presentationSetup = "Not prepared" + @FocusState private var focused: Bool + private var owned: Bool { ProcessInfo.processInfo.arguments.contains("--maple-owned-composer") } + var body: some View { + if ProcessInfo.processInfo.arguments.contains("--maple-composer-order-check") { + VStack { + Button("Check input ordering") { + orderingStatus = "Checking" + Task { @MainActor in orderingStatus = await ComposerEditOrderingProbe.run() } + } + .disabled(orderingStatus == "Checking") + .accessibilityIdentifier("composer.order-check") + Text(orderingStatus).font(.caption2).lineLimit(12) + .accessibilityLabel(orderingStatus).accessibilityIdentifier("composer.order-status") + }.padding() + } else if ProcessInfo.processInfo.arguments.contains("--maple-return-resign-control") { + ComposerReturnResignFixtureView() + } else if ProcessInfo.processInfo.arguments.contains("--maple-native-keyboard-control") { + ComposerStandaloneNativeControl() + } else { + mixedFixture + } + } + private var mixedFixture: some View { + let renderedOwner = ownerID + return VStack(alignment: .leading, spacing: 16) { + Text("SwiftUI keyboard probe").font(.title2) + Text("Sends \(sends)").accessibilityIdentifier("composer.sends") + Text("Newlines \(text.filter { $0 == "\n" }.count)").accessibilityIdentifier("composer.newlines") + Text("Marked \(marked ? 1 : 0)").accessibilityIdentifier("composer.marked") + if owned && ProcessInfo.processInfo.arguments.contains("--maple-composer-presentation-check") { + Button("Send with owned Return command") { + guard let view = Self.fixtureInput() as? ComposerTextView, + let command = view.keyCommands?.first(where: { + $0.action == #selector(ComposerTextView.handleComposerReturn(_:)) + && $0.input == "\r" && $0.modifierFlags.isEmpty + }), let action = command.action, + view.canPerformAction(action, withSender: command) + else { presentationSetup = "Command unavailable"; return } + // Controlled command dispatch sets up the presentation test. + // Actual hardware receipt remains a separate runtime proof. + _ = view.perform(action, with: command) + presentationSetup = "Owned command dispatched" + }.accessibilityIdentifier("composer.presentation-send") + Text(presentationSetup).accessibilityIdentifier("composer.presentation-status") + } + Text(nativeMarkStatus).accessibilityIdentifier("composer.native-marked") + Text("Key events \(keyEvents); Return events \(returnEvents)") + .accessibilityIdentifier("composer.events") + if owned { + ComposerEditor( + text: $text, focused: $nativeFocused, ownerID: ownerID, + placeholder: "Synthetic composer", accessibilityIdentifier: "composer.probe", + onChange: { state, edits in + ownerEvents.record(owner: renderedOwner, marked: state.markedRangeUTF16 != nil) + marked = state.markedRangeUTF16 != nil + editCount += edits.count + invalidEdits += edits.filter { $0.resultingText == nil }.count + if !edits.isEmpty { + transactionSummary = edits.map { + "\($0.rangeUTF16.location):\($0.rangeUTF16.length):\($0.replacement.utf16.count)" + }.joined(separator: "|") + for edit in edits { _ = insertionMarker?.rebase(using: edit) } + } + if let edit = edits.last { + editSummary = + "Edit \(edit.rangeUTF16.location):\(edit.rangeUTF16.length):\(edit.replacement.utf16.count); invalid \(invalidEdits)" + } + }, + onSubmit: { + guard ownerID == renderedOwner else { return } + sends += 1; text = ""; nativeFocused = false + } + ) + .padding(8).background(.secondary.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) + Text(editSummary).font(.caption).accessibilityIdentifier("composer.edit-summary") + HStack { + Button("Repeated text") { + text = "aaaa"; insertionMarker = DictationInsertionAnchor(text: "aaaa", offsetUTF16: 2) + }.accessibilityIdentifier("composer.repeated") + Button("Delete first") { + guard let input = Self.fixtureInput(), + let end = input.position(from: input.beginningOfDocument, offset: 1), + let range = input.textRange(from: input.beginningOfDocument, to: end) + else { return } + input.replace(range, withText: "") + }.accessibilityIdentifier("composer.delete-first") + Button("Commit composition") { Self.fixtureInput()?.unmarkText() } + .accessibilityIdentifier("composer.unmark") + Button("Clear") { text = "" }.accessibilityIdentifier("composer.clear") + }.font(.caption) + HStack { + Button("Batch edits") { Self.batchFixtureEdits() }.accessibilityIdentifier("composer.batch") + Text("Marker \(insertionMarker?.offsetUTF16 ?? -1)").accessibilityIdentifier("composer.marker") + Text(transactionSummary).accessibilityIdentifier("composer.transactions") + }.font(.caption) + HStack { + Button("Switch draft") { + previousOwner = ownerID; ownerID = UUID() + } + .accessibilityIdentifier("composer.switch-owner") + Button("Read previous owner") { + if let previousOwner { + previousOwnerStatus = + ownerEvents.latest[previousOwner] == false + ? "Previous owner committed" + : "Previous owner uncommitted; events \(ownerEvents.counts[previousOwner, default: 0])" + } + }.accessibilityIdentifier("composer.read-previous-owner") + Text(previousOwnerStatus).accessibilityIdentifier("composer.previous-owner") + }.font(.caption) + HStack { + Button("Check owner callback") { reentrantStatus = Self.checkReentrantOwner() } + .accessibilityIdentifier("composer.reentrant-check") + Text(reentrantStatus).accessibilityIdentifier("composer.reentrant-status") + }.font(.caption) + if ProcessInfo.processInfo.arguments.contains("--maple-composer-order-events") { + HStack { + Button("Record input order") { + guard let view = Self.fixtureInput() as? ComposerTextView, + let coordinator = view.delegate as? ComposerEditor.Coordinator + else { inputOrderStatus = "Owned coordinator unavailable"; return } + inputEvents.reset() + let recorder = inputEvents + coordinator.inputOrderObserver = { [weak recorder] in recorder?.record($0) } + inputOrderStatus = "Recording synthetic input order" + }.accessibilityIdentifier("composer.order-record") + Button("Read input order") { + inputOrderStatus = inputEvents.snapshot + } + .accessibilityIdentifier("composer.order-read") + }.font(.caption) + Button("Copy input diagnostics") { + // Explicit synthetic-only export, separate from the app's + // message Copy action. No text, account or owner ID is copied. + let snapshot = + inputEvents.snapshot + + "\nfixture sends=\(sends); newlines=\(text.filter { $0 == "\n" }.count); " + + "invalidEdits=\(invalidEdits)\n" + UIPasteboard.general.setItems( + [[UTType.utf8PlainText.identifier: Data(snapshot.utf8)]], + options: [.localOnly: true, .expirationDate: Date().addingTimeInterval(120)]) + inputOrderStatus = + "Copied input diagnostics; recorded=\(inputEvents.entries.count); dropped=\(inputEvents.dropped)" + } + .font(.caption).accessibilityIdentifier("composer.order-copy") + Text(inputOrderStatus).font(.caption2).lineLimit(4, reservesSpace: true) + .accessibilityLabel(inputOrderStatus).accessibilityIdentifier("composer.order-events") + } + } else { + TextField("Synthetic composer", text: $text, axis: .vertical) + .lineLimit(1...8).focused($focused).textFieldStyle(.roundedBorder) + .accessibilityIdentifier("composer.probe") + .onKeyPress(phases: .down) { press in + keyEvents += 1 + guard press.key == .return else { return .ignored } + returnEvents += 1 + guard press.modifiers.isEmpty else { return .ignored } + sends += 1 + text = "" + focused = false + return .handled + } + } + HStack { + Button("Mark Japanese text") { + guard let input = Self.fixtureInput(activateOwned: owned) else { + nativeMarkStatus = "Owned input unavailable"; marked = false; return + } + input.setMarkedText("にほん", selectedRange: NSRange(location: 3, length: 0)) + nativeMarkStatus = input.markedTextRange != nil ? "Native marked 1" : "Native marked 0" + marked = input.markedTextRange != nil + }.accessibilityIdentifier("composer.mark") + Button("Read composition") { marked = Self.fixtureInput()?.markedTextRange != nil } + .accessibilityIdentifier("composer.read-mark") + } + Text("Native UITextView control").font(.caption) + ComposerNativeInputControl(text: $nativeText).frame(height: 64) + Text("Native newlines \(nativeText.filter { $0 == "\n" }.count)") + .accessibilityIdentifier("composer.native-newlines") + Spacer() + } + .padding() + .onAppear { focused = !owned } + } + + // Debug probe only. This uses public responder/text-input protocols to set + // controlled marked text; product code must not depend on SwiftUI hierarchy. + @MainActor private static func fixtureInput(activateOwned: Bool = false) -> (any UITextInput)? { + var pending = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + .flatMap(\.windows).filter(\.isKeyWindow).map { $0 as UIView } + var firstResponder: (any UITextInput)? + var visited = 0 + while let view = pending.popLast(), visited < 1024 { + visited += 1 + if let owned = view as? ComposerTextView, owned.accessibilityIdentifier == "composer.probe" { + if activateOwned && !owned.isFirstResponder && !owned.becomeFirstResponder() { return nil } + return owned + } + if view.isFirstResponder, let input = view as? any UITextInput { firstResponder = input } + pending.append(contentsOf: view.subviews.prefix(max(0, 1024 - visited - pending.count))) + } + return activateOwned ? nil : firstResponder + } + + // Exercises the production Coordinator with a binding that synchronously + // installs another owner. No account data or global view hooks are involved. + @MainActor private static func checkReentrantOwner() -> String { + let oldOwner = UUID(), newOwner = UUID() + var activeOwner = oldOwner + var oldText = "" + var oldReceived = "", newReceived = "" + var oldSends = 0, newSends = 0, staleDropped = 0 + let view = ComposerTextView(frame: .zero, textContainer: nil) + var coordinator: ComposerEditor.Coordinator! + let next = ComposerEditor( + text: .constant("new owner"), focused: .constant(false), ownerID: newOwner, + placeholder: "Synthetic new owner", + onChange: { state, _ in newReceived = state.text }, + onSubmit: { if activeOwner == newOwner { newSends += 1 } }) + let old = ComposerEditor( + text: Binding( + get: { oldText }, + set: { value in + oldText = value + activeOwner = newOwner + coordinator.install(next, in: view) + }), + focused: .constant(false), ownerID: oldOwner, placeholder: "Synthetic old owner", + onChange: { state, _ in oldReceived = state.text }, + onSubmit: { + guard activeOwner == oldOwner else { staleDropped += 1; return } + oldSends += 1 + }) + coordinator = old.makeCoordinator() + coordinator.view = view + coordinator.install(old, in: view) + defer { + coordinator.valid = false + coordinator.parent = next + coordinator.view = nil + } + coordinator.publish() + oldReceived = "" + view.text = "synthetic old input" + coordinator.submit() + return oldReceived == "synthetic old input" && newReceived != "synthetic old input" + && oldSends == 0 && newSends == 0 && staleDropped == 0 + ? "Owner callbacks fenced" : "Owner callbacks failed" + } + + @MainActor private static func batchFixtureEdits() { + guard let view = fixtureInput() as? ComposerTextView, view.text == "aaaa" else { return } + view.textStorage.beginEditing() + defer { view.textStorage.endEditing() } + for (offset, replacement) in [(0, "b"), (3, "c")] { + guard let start = view.position(from: view.beginningOfDocument, offset: offset), + let end = view.position(from: start, offset: 1), let range = view.textRange(from: start, to: end) + else { return } + view.replace(range, withText: replacement) + } + // Attribute fixing broadens the aggregate storage span, but must not + // broaden the independently captured character replacement events. + view.textStorage.addAttribute( + .foregroundColor, value: UIColor.label, + range: NSRange(location: 0, length: view.textStorage.length)) + } + } + + /// Positive control with no sibling editor, SwiftUI focus binding, key handler, + /// or production ComposerEditor that could intercept native Return events. + private struct ComposerStandaloneNativeControl: View { + @State private var text = "" + @State private var diagnostics = "Not observed" + var body: some View { + VStack(alignment: .leading, spacing: 20) { + Text("Standalone native UITextView control").font(.headline) + ComposerNativeInputControl(text: $text, diagnostics: $diagnostics).frame(height: 160) + Text(diagnostics).font(.caption2).accessibilityIdentifier("composer.native-diagnostics") + Text("Native newlines \(text.filter { $0 == "\n" }.count)") + .accessibilityIdentifier("composer.native-newlines") + Spacer() + }.padding() + } + } + + private struct ComposerNativeInputControl: UIViewRepresentable { + @Binding var text: String + @Binding var diagnostics: String + init(text: Binding, diagnostics: Binding = .constant("")) { + _text = text; _diagnostics = diagnostics + } + func makeCoordinator() -> Coordinator { Coordinator(text: $text, diagnostics: $diagnostics) } + func makeUIView(context: Context) -> ComposerObservedNativeControl { + let view = ComposerObservedNativeControl() + view.accessibilityIdentifier = "composer.native-control" + view.font = .preferredFont(forTextStyle: .body) + view.backgroundColor = .secondarySystemBackground + view.delegate = context.coordinator + view.observed = { [weak view, weak coordinator = context.coordinator] in + guard let view else { return } + coordinator?.publish(view) + } + return view + } + func updateUIView(_ view: ComposerObservedNativeControl, context: Context) { + context.coordinator.text = $text + context.coordinator.diagnostics = $diagnostics + if view.text != text, view.markedTextRange == nil { view.text = text } + } + static func dismantleUIView(_ view: ComposerObservedNativeControl, coordinator: Coordinator) { + view.observed = nil + view.delegate = nil + view.clearRetainedReturn() + } + @MainActor final class Coordinator: NSObject, UITextViewDelegate { + var text: Binding + var diagnostics: Binding + var changeCount = 0 + init(text: Binding, diagnostics: Binding) { + self.text = text; self.diagnostics = diagnostics + } + func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) + -> Bool + { + (textView as? ComposerObservedNativeControl)?.observeNewlineProposal(text, range: range) + return true + } + func textViewDidChange(_ textView: UITextView) { + changeCount += 1 + text.wrappedValue = textView.text + if let view = textView as? ComposerObservedNativeControl { publish(view) } + } + func publish(_ view: ComposerObservedNativeControl) { + let scalars = (view.text ?? "").unicodeScalars.prefix(64).map { String($0.value, radix: 16) } + .joined(separator: ",") + let value = + "Scalars \(scalars); selection \(view.selectedRange.location):\(view.selectedRange.length); " + + "changes \(changeCount); presses \(view.presses); keys \(view.keys); inserted \(view.inserted); " + + view.returnScopeDiagnostics + if diagnostics.wrappedValue != value { diagnostics.wrappedValue = value } + } + } + } + + /// Observation-only native control. Overrides always forward the original + /// inputs unchanged; neither key commands nor Return behavior is implemented. + @MainActor private final class ComposerObservedNativeControl: UITextView { + var observed: (() -> Void)? + var presses = 0 + var keys = "" + var inserted = "" + private var returnScopeDepth = 0 + private var returnScopes = 0 + private var proposalInside = 0 + private var proposalOutside = 0 + private var insertionInside = 0 + private var insertionOutside = 0 + private var scopeDropped = 0 + private var proposalScope = false + private var insertionScope = false + private var proposalBefore = -1 + private var insertionBefore = -1 + private var proposalRange = NSRange(location: NSNotFound, length: 0) + private var retainedReturn: UIPress? + private var returnEnded = false + private var returnCancelled = false + private var lifetimeEvents: [String] = [] + private var lifetimeDropped = 0 + private var retainedReplacements = 0 + private enum LifetimeEvent: Int { + case beganEntry = 1, beganExit, changedEntry, changedExit + case endedEntry, endedExit, cancelledEntry, cancelledExit + case newlineProposal, newlineInsertionEntry, newlineInsertionExit + } + var returnScopeDiagnostics: String { + "returnScopes \(returnScopes); scopeDepth \(returnScopeDepth); " + + "newlineProposal \(proposalInside):\(proposalOutside); proposalScope \(proposalScope); " + + "proposalBefore \(proposalBefore); proposalRange \(proposalRange.location):\(proposalRange.length); " + + "newlineInsertion \(insertionInside):\(insertionOutside); insertionScope \(insertionScope); " + + "insertionBefore \(insertionBefore); scopeDropped \(scopeDropped); " + + "lifetimeDropped \(lifetimeDropped); retainedReplacements \(retainedReplacements); " + + "lifetime[ordinal:kind:hid:mask:phase:ended:cancelled:matched] \(lifetimeEvents.joined(separator: ";"))" + } + func clearRetainedReturn() { + retainedReturn = nil + returnEnded = false + returnCancelled = false + } + private func isReturn(_ press: UIPress) -> Bool { + press.key?.keyCode == .keyboardReturnOrEnter || press.key?.keyCode == .keypadEnter + } + private func matchesRetained(_ presses: Set) -> Bool { + guard let retainedReturn else { return false } + return presses.contains { $0 === retainedReturn } + } + private func recordLifetime(_ kind: LifetimeEvent, matched: Int = -1) { + guard lifetimeEvents.count < 64 else { + lifetimeDropped = min(lifetimeDropped + 1, 256) + return + } + let hid = retainedReturn?.key.map { Int($0.keyCode.rawValue) } ?? -1 + let mask = retainedReturn?.key.map { Int($0.modifierFlags.rawValue) } ?? -1 + let phase = retainedReturn.map { Int($0.phase.rawValue) } ?? -1 + lifetimeEvents.append( + "\(lifetimeEvents.count + 1):\(kind.rawValue):\(hid):\(mask):\(phase):" + + "\(returnEnded ? 1 : 0):\(returnCancelled ? 1 : 0):\(matched)") + } + private func increment(_ value: inout Int) { + if value < 256 { value += 1 } else { scopeDropped = min(scopeDropped + 1, 256) } + } + func observeNewlineProposal(_ text: String, range: NSRange) { + guard text == "\n" || text == "\r" else { return } + proposalScope = returnScopeDepth > 0 + proposalBefore = (self.text ?? "").utf16.count + proposalRange = range + if proposalScope { increment(&proposalInside) } else { increment(&proposalOutside) } + recordLifetime(.newlineProposal) + } + override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { + self.presses += presses.count + let hardwareKeys = presses.compactMap { $0.key } + keys = hardwareKeys.map { + "\($0.keyCode.rawValue):\($0.modifierFlags.rawValue)" + }.sorted().joined(separator: ",") + let hasReturn = hardwareKeys.contains { + $0.keyCode == .keyboardReturnOrEnter || $0.keyCode == .keypadEnter + } + if let press = presses.first(where: { isReturn($0) }) { + if retainedReturn != nil { retainedReplacements = min(retainedReplacements + 1, 256) } + retainedReturn = press + returnEnded = false + returnCancelled = false + recordLifetime(.beganEntry, matched: 1) + } + let enteredScope = hasReturn && returnScopeDepth < 256 + if enteredScope { + returnScopeDepth += 1 + increment(&returnScopes) + } else if hasReturn { + scopeDropped = min(scopeDropped + 1, 256) + } + // Observe only this synchronous super call. No scope survives it, + // and every original input is forwarded unchanged. + super.pressesBegan(presses, with: event) + if enteredScope { returnScopeDepth -= 1 } + if hasReturn { recordLifetime(.beganExit, matched: matchesRetained(presses) ? 1 : 0) } + observed?() + } + override func pressesChanged(_ presses: Set, with event: UIPressesEvent?) { + let hasReturn = presses.contains(where: { isReturn($0) }) + if hasReturn { recordLifetime(.changedEntry, matched: matchesRetained(presses) ? 1 : 0) } + super.pressesChanged(presses, with: event) + if hasReturn { + recordLifetime(.changedExit, matched: matchesRetained(presses) ? 1 : 0) + observed?() + } + } + override func pressesEnded(_ presses: Set, with event: UIPressesEvent?) { + let hasReturn = presses.contains(where: { isReturn($0) }) + if matchesRetained(presses) { returnEnded = true } + if hasReturn { recordLifetime(.endedEntry, matched: matchesRetained(presses) ? 1 : 0) } + super.pressesEnded(presses, with: event) + if hasReturn { + recordLifetime(.endedExit, matched: matchesRetained(presses) ? 1 : 0) + observed?() + } + } + override func pressesCancelled(_ presses: Set, with event: UIPressesEvent?) { + let hasReturn = presses.contains(where: { isReturn($0) }) + if matchesRetained(presses) { returnCancelled = true } + if hasReturn { recordLifetime(.cancelledEntry, matched: matchesRetained(presses) ? 1 : 0) } + super.pressesCancelled(presses, with: event) + if hasReturn { + recordLifetime(.cancelledExit, matched: matchesRetained(presses) ? 1 : 0) + observed?() + } + } + override func insertText(_ text: String) { + inserted = text.unicodeScalars.prefix(64).map { String($0.value, radix: 16) }.joined(separator: ",") + if text == "\n" || text == "\r" { + insertionScope = returnScopeDepth > 0 + insertionBefore = (self.text ?? "").utf16.count + if insertionScope { increment(&insertionInside) } else { increment(&insertionOutside) } + recordLifetime(.newlineInsertionEntry) + } + super.insertText(text) + if text == "\n" || text == "\r" { recordLifetime(.newlineInsertionExit) } + observed?() + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Composer/ComposerListContinuation.swift b/apps/maple-ios/Sources/MapleAppUI/Composer/ComposerListContinuation.swift new file mode 100644 index 000000000..6594519ad --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Composer/ComposerListContinuation.swift @@ -0,0 +1,122 @@ +import Foundation + +/// Research's newline edit, expressed as the exact native replacement rather +/// than a character diff so dictation insertion anchors retain their meaning. +struct ComposerListEdit: Equatable, Sendable { + let rangeUTF16: NSRange + let replacement: String + let selectionUTF16: NSRange +} + +enum ComposerListContinuation { + private static let unordered = try? NSRegularExpression(pattern: #"^([ \t]*)([-+*])([ \t]+)(.*)\z"#) + private static let taskMarker = try? NSRegularExpression(pattern: #"^\[([ xX])\]([ \t]*)(.*)\z"#) + private static let ordered = try? NSRegularExpression(pattern: #"^([ \t]*)([0-9]+)([.)])([ \t]+)(.*)\z"#) + private static let fenceOpening = try? NSRegularExpression(pattern: #"^[ ]{0,3}(`{3,}|~{3,})"#) + private static let fenceClosing = try? NSRegularExpression(pattern: #"^[ ]{0,3}(`+|~+)[ \t]*\z"#) + private static let rule = try? NSRegularExpression(pattern: #"^((?:-\s*){3,}|(?:\*\s*){3,})\z"#) + + private struct Marker { + let indentation: String + let continuation: String + let item: String + } + + static func edit(text: String, selectionUTF16 selection: NSRange) -> ComposerListEdit? { + let source = text as NSString + guard selection.location != NSNotFound, selection.location >= 0, selection.length >= 0, + selection.location <= source.length, selection.length <= source.length - selection.location, + scalarBoundary(selection.location, in: text), scalarBoundary(NSMaxRange(selection), in: text), + Range(selection, in: text) != nil + else { return nil } + let previousBreak = source.range( + of: "\n", options: .backwards, range: NSRange(location: 0, length: selection.location)) + let lineStart = previousBreak.location == NSNotFound ? 0 : previousBreak.location + 1 + let followingBreak = source.range( + of: "\n", range: NSRange(location: selection.location, length: source.length - selection.location)) + let lineEnd = followingBreak.location == NSNotFound ? source.length : followingBreak.location + let line = source.substring(with: NSRange(location: lineStart, length: lineEnd - lineStart)) + guard !horizontalRule(line), + let marker = listMarker( + source.substring(with: NSRange(location: lineStart, length: selection.location - lineStart))), + !insideFence(source.substring(to: lineStart)) + else { return nil } + if let full = listMarker(line), full.item.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + let caret = lineStart + marker.indentation.utf16.count + return ComposerListEdit( + rangeUTF16: NSRange(location: caret, length: max(lineEnd, NSMaxRange(selection)) - caret), + replacement: "", selectionUTF16: NSRange(location: caret, length: 0)) + } + let replacement = "\n" + marker.continuation + return ComposerListEdit( + rangeUTF16: selection, replacement: replacement, + selectionUTF16: NSRange(location: selection.location + replacement.utf16.count, length: 0)) + } + + private static func scalarBoundary(_ offset: Int, in text: String) -> Bool { + let index = text.utf16.index(text.utf16.startIndex, offsetBy: offset) + return index.samePosition(in: text.unicodeScalars) != nil + } + + private static func listMarker(_ line: String) -> Marker? { + if let parts = captures(unordered, in: line) { + if let task = captures(taskMarker, in: parts[3]), + !task[1].isEmpty || task[2].isEmpty + { + return Marker( + indentation: parts[0], continuation: parts[0] + parts[1] + parts[2] + "[ ]" + task[1], + item: task[2]) + } + return Marker(indentation: parts[0], continuation: parts[0] + parts[1] + parts[2], item: parts[3]) + } + guard let parts = captures(ordered, in: line) else { return nil } + return Marker( + indentation: parts[0], continuation: parts[0] + increment(parts[1]) + parts[2] + parts[3], item: parts[4]) + } + + // JavaScript's BigInt increments without a machine-integer limit and drops + // leading zeroes. Keep that behavior for pasted or very long list markers. + private static func increment(_ decimal: String) -> String { + var digits = Array(decimal.utf8.drop(while: { $0 == 48 })) + if digits.isEmpty { return "1" } + for index in digits.indices.reversed() { + if digits[index] != 57 { + digits[index] += 1 + return String(decoding: digits, as: UTF8.self) + } + digits[index] = 48 + } + return "1" + String(decoding: digits, as: UTF8.self) + } + + private static func insideFence(_ prefix: String) -> Bool { + var fence: (marker: Character, length: Int)? + for line in prefix.components(separatedBy: "\n") { + if let active = fence { + if let closing = captures(fenceClosing, in: line), + closing[0].first == active.marker, closing[0].count >= active.length + { + fence = nil + } + } else if let opening = captures(fenceOpening, in: line), + let marker = opening[0].first + { + fence = (marker, opening[0].count) + } + } + return fence != nil + } + + private static func horizontalRule(_ line: String) -> Bool { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + return captures(rule, in: trimmed) != nil + } + + private static func captures(_ expression: NSRegularExpression?, in text: String) -> [String]? { + guard let expression, + let match = expression.firstMatch(in: text, range: NSRange(location: 0, length: text.utf16.count)) + else { return nil } + let source = text as NSString + return (1.. Coordinator { Coordinator(text: $text) } + func makeUIView(context: Context) -> ReturnResignTextView { + let view = ReturnResignTextView() + view.font = .preferredFont(forTextStyle: .body) + view.backgroundColor = .secondarySystemBackground + view.accessibilityIdentifier = "composer.resign-control" + view.delegate = context.coordinator + view.ledger = ledger + ledger.view = view + view.bindingLength = { [weak coordinator = context.coordinator] in + coordinator?.text.wrappedValue.utf16.count ?? -1 + } + return view + } + + func updateUIView(_ view: ReturnResignTextView, context: Context) { + context.coordinator.text = $text + // This control never reinstalls a model snapshot into native text. + // The native input system alone owns its synthetic text mutations. + } + + static func dismantleUIView(_ view: ReturnResignTextView, coordinator: Coordinator) { + view.valid = false + view.commandOwner = UUID() + view.bindingLength = nil + view.ledger?.view = nil + view.ledger = nil + view.delegate = nil + } + + @MainActor final class Coordinator: NSObject, UITextViewDelegate { + var text: Binding + init(text: Binding) { self.text = text } + + func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) + -> Bool + { + (textView as? ReturnResignTextView)?.record( + .proposal, inputLength: text.utf16.count, range: range) + return true + } + + func textViewDidChange(_ textView: UITextView) { + guard let view = textView as? ReturnResignTextView else { return } + view.record(.changeBeforeBinding) + text.wrappedValue = textView.text + view.record(.changeAfterBinding) + } + + func textViewDidEndEditing(_ textView: UITextView) { + (textView as? ReturnResignTextView)?.record(.didEndEditing) + } + } + } + + @MainActor private final class ReturnResignTextView: UITextView { + enum Event: String { + case proposal, changeBeforeBinding, changeAfterBinding, didEndEditing + case insertBegin, insertEnd, commandEntry, resignBegin, resignEnd, wouldSubmit + case pressBeganEntry, pressBeganExit, pressChangedEntry, pressChangedExit + case pressEndedEntry, pressEndedExit, pressCancelledEntry, pressCancelledExit + } + + var valid = true + var commandOwner = UUID() + var bindingLength: (() -> Int)? + var ledger: ReturnResignLedger? + private var operationDepth = 0 + private var resignDepth = 0 + private var insertions = 0 + private var insertionsDuringResign = 0 + private var rawOrdinal = 0 + private var commands = 0 + private var wouldSubmits = 0 + + private var acceptsCommand: Bool { + valid && isEditable && isFirstResponder && markedTextRange == nil + && operationDepth == 0 && resignDepth == 0 + && !textStorage.editedMask.contains(.editedCharacters) + } + + override var keyCommands: [UIKeyCommand]? { + let inherited = super.keyCommands ?? [] + guard acceptsCommand else { return inherited } + return ["\r", "\u{3}"].map { input in + let command = UIKeyCommand( + title: "", action: #selector(measureReturn(_:)), input: input, + modifierFlags: [], propertyList: commandOwner.uuidString) + command.wantsPriorityOverSystemBehavior = true + return command + } + inherited + } + + private func owns(_ command: UIKeyCommand) -> Bool { + command.action == #selector(measureReturn(_:)) + && command.propertyList as? String == commandOwner.uuidString + && (command.input == "\r" || command.input == "\u{3}") + && command.modifierFlags.subtracting(.numericPad).isEmpty + } + + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + if action == #selector(measureReturn(_:)) { + if sender == nil { return acceptsCommand } + guard let command = sender as? UIKeyCommand else { return false } + return owns(command) && acceptsCommand + } + return super.canPerformAction(action, withSender: sender) + } + + @objc private func measureReturn(_ command: UIKeyCommand) { + let eligible = owns(command) && acceptsCommand + commands = min(commands + 1, 1_000_000) + record(.commandEntry, mask: command.modifierFlags.rawValue, eligible: eligible ? 1 : 0) + guard eligible, let admittedWindow = window else { return } + let admittedOwner = commandOwner + resignDepth += 1 + record(.resignBegin) + let resigned = resignFirstResponder() + record(.resignEnd, success: resigned ? 1 : 0) + resignDepth -= 1 + guard resigned, valid, commandOwner == admittedOwner, window === admittedWindow, + !isFirstResponder, markedTextRange == nil + else { return } + wouldSubmits = min(wouldSubmits + 1, 1_000_000) + // Only a fixed synthetic comparator is exported. No text is submitted, + // cleared, persisted, logged or passed into the application runtime. + record(.wouldSubmit, success: 1, exactABC: text == "abc" ? 1 : 0) + } + + override func insertText(_ text: String) { + operationDepth += 1 + insertions = min(insertions + 1, 1_000_000) + if resignDepth > 0 { insertionsDuringResign = min(insertionsDuringResign + 1, 1_000_000) } + record(.insertBegin, inputLength: text.utf16.count) + super.insertText(text) + record(.insertEnd, inputLength: text.utf16.count) + operationDepth -= 1 + } + + func record( + _ event: Event, inputLength: Int = -1, range: NSRange? = nil, + count: Int = -1, hid: Int = -1, mask: Int = 0, + success: Int = -1, eligible: Int = -1, exactABC: Int = -1 + ) { + guard valid else { return } + ledger?.append( + "event=\(event.rawValue); native=\((text ?? "").utf16.count); binding=\(bindingLength?() ?? -1); " + + "insertions=\(insertions); duringResign=\(insertionsDuringResign); resignDepth=\(resignDepth); " + + "operationDepth=\(operationDepth); firstResponder=\(isFirstResponder ? 1 : 0); " + + "marked=\(markedTextRange == nil ? 0 : 1); rawOrdinal=\(rawOrdinal); commands=\(commands); " + + "wouldSubmits=\(wouldSubmits); inputLength=\(inputLength); range=\(range?.location ?? -1):\(range?.length ?? -1); " + + "pressCount=\(count); returnHID=\(hid); mask=\(mask); success=\(success); eligible=\(eligible); exactABC=\(exactABC)" + ) + } + + private func recordPress(_ event: Event, _ presses: Set) { + let key = presses.compactMap(\.key).first { + $0.keyCode == .keyboardReturnOrEnter || $0.keyCode == .keypadEnter + } + record( + event, count: presses.count, hid: key.map { Int($0.keyCode.rawValue) } ?? -1, + mask: key?.modifierFlags.rawValue ?? 0) + } + + override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { + rawOrdinal = min(rawOrdinal + 1, 1_000_000) + recordPress(.pressBeganEntry, presses) + super.pressesBegan(presses, with: event) + recordPress(.pressBeganExit, presses) + } + override func pressesChanged(_ presses: Set, with event: UIPressesEvent?) { + rawOrdinal = min(rawOrdinal + 1, 1_000_000) + recordPress(.pressChangedEntry, presses) + super.pressesChanged(presses, with: event) + recordPress(.pressChangedExit, presses) + } + override func pressesEnded(_ presses: Set, with event: UIPressesEvent?) { + rawOrdinal = min(rawOrdinal + 1, 1_000_000) + recordPress(.pressEndedEntry, presses) + super.pressesEnded(presses, with: event) + recordPress(.pressEndedExit, presses) + } + override func pressesCancelled(_ presses: Set, with event: UIPressesEvent?) { + rawOrdinal = min(rawOrdinal + 1, 1_000_000) + recordPress(.pressCancelledEntry, presses) + super.pressesCancelled(presses, with: event) + recordPress(.pressCancelledExit, presses) + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Dictation/AVDictationCapture.swift b/apps/maple-ios/Sources/MapleAppUI/Dictation/AVDictationCapture.swift new file mode 100644 index 000000000..5492c487f --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Dictation/AVDictationCapture.swift @@ -0,0 +1,148 @@ +#if os(iOS) + import AVFoundation + import Foundation + import MapleAppCore + import Synchronization + + /// Uses an audio-engine tap and bounded memory. It never creates a recording + /// URL or writes microphone samples to a file or protected draft store. + @MainActor public final class AVDictationCapture: DictationCapturing { + private var active: Capture? + + private struct BufferState { + let converter: AVAudioConverter + let output: AVAudioFormat + let maximumBytes: Int + var pcm = Data() + var reachedLimit = false + var failed = false + } + + private struct Capture { + let id: UUID + let engine: AVAudioEngine + let buffer: CaptureBuffer + let interruption: NSObjectProtocol + let routeChange: NSObjectProtocol + } + + private final class CaptureBuffer: Sendable { + let state: Mutex + init(_ state: sending BufferState) { self.state = Mutex(state) } + } + + /// AVAudioNode lends this buffer to the tap. The unchecked bridge retains + /// it read-only, solely for synchronous conversion before the tap returns; + /// neither samples nor this wrapper enter a Task or the active capture. + /// AVAudioConverter requires stable bytes until its next input callback + /// or conversion completion. Only one-shot availability is mutable. + private final class ConversionInput: @unchecked Sendable { + private let samples: AVAudioPCMBuffer + private let available = Mutex(true) + init(_ samples: AVAudioPCMBuffer) { self.samples = samples } + func take() -> AVAudioPCMBuffer? { + let first = available.withLock { value in + guard value else { return false } + value = false; return true + } + return first ? samples : nil + } + } + + public init() {} + + public func requestPermission() async -> Bool { + await AVAudioApplication.requestRecordPermission() + } + + public func start( + id: UUID, maximumFrames: Int, onLimit: @escaping @MainActor @Sendable () -> Void + ) throws { + guard active == nil, maximumFrames > 0, maximumFrames <= DictationAudio.maximumFrames else { + throw DictationIssue.captureUnavailable + } + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playAndRecord, mode: .measurement, options: [.defaultToSpeaker]) + try session.setActive(true) + let engine = AVAudioEngine() + let input = engine.inputNode + let inputFormat = input.outputFormat(forBus: 0) + guard inputFormat.sampleRate > 0, inputFormat.channelCount > 0, + let output = AVAudioFormat( + commonFormat: .pcmFormatInt16, sampleRate: Double(DictationAudio.sampleRate), + channels: 1, interleaved: false), + let converter = AVAudioConverter(from: inputFormat, to: output) + else { + try? session.setActive(false, options: .notifyOthersOnDeactivation) + throw DictationIssue.captureUnavailable + } + let buffer = CaptureBuffer( + BufferState(converter: converter, output: output, maximumBytes: maximumFrames * 2)) + input.installTap(onBus: 0, bufferSize: 2_048, format: inputFormat) { @Sendable samples, _ in + let shouldStop = buffer.state.withLock { state -> Bool in + guard !state.reachedLimit, !state.failed else { return false } + let capacity = AVAudioFrameCount( + ceil( + Double(samples.frameLength) * Double(DictationAudio.sampleRate) / samples.format.sampleRate) + + 32) + guard let converted = AVAudioPCMBuffer(pcmFormat: state.output, frameCapacity: capacity) else { + state.failed = true; return true + } + let input = ConversionInput(samples) + var error: NSError? + let status = state.converter.convert(to: converted, error: &error) { _, inputStatus in + guard let samples = input.take() else { inputStatus.pointee = .noDataNow; return nil } + inputStatus.pointee = .haveData; return samples + } + guard status != .error, error == nil, let channel = converted.int16ChannelData?.pointee else { + state.failed = true; return true + } + let count = min(Int(converted.frameLength) * 2, state.maximumBytes - state.pcm.count) + if count > 0 { + state.pcm.append( + contentsOf: UnsafeBufferPointer( + start: UnsafeRawPointer(channel).assumingMemoryBound(to: UInt8.self), count: count)) + } + if state.pcm.count == state.maximumBytes { state.reachedLimit = true; return true } + return false + } + if shouldStop { Task { @MainActor in onLimit() } } + } + let interruption = NotificationCenter.default.addObserver( + forName: AVAudioSession.interruptionNotification, object: session, queue: nil + ) { _ in Task { @MainActor in onLimit() } } + let routeChange = NotificationCenter.default.addObserver( + forName: AVAudioSession.routeChangeNotification, object: session, queue: nil + ) { _ in Task { @MainActor in onLimit() } } + active = Capture( + id: id, engine: engine, buffer: buffer, interruption: interruption, routeChange: routeChange) + do { engine.prepare(); try engine.start() } catch { + cancel(id: id); throw DictationIssue.captureUnavailable + } + } + + public func stop(id: UUID) throws -> Data { + guard let capture = detach(id: id) else { throw DictationIssue.captureUnavailable } + return try capture.buffer.state.withLock { state in + guard !state.failed else { throw DictationIssue.captureUnavailable } + let pcm = state.pcm; state.pcm = Data(); return pcm + } + } + + public func cancel(id: UUID) { + guard let capture = detach(id: id) else { return } + capture.buffer.state.withLock { $0.pcm = Data() } + } + + private func detach(id: UUID) -> Capture? { + guard let capture = active, capture.id == id else { return nil } + active = nil + NotificationCenter.default.removeObserver(capture.interruption) + NotificationCenter.default.removeObserver(capture.routeChange) + capture.engine.inputNode.removeTap(onBus: 0) + capture.engine.stop() + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + return capture + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Dictation/Dictation.xcstrings b/apps/maple-ios/Sources/MapleAppUI/Dictation/Dictation.xcstrings new file mode 100644 index 000000000..2942dfe5a --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Dictation/Dictation.xcstrings @@ -0,0 +1,230 @@ +{ + "sourceLanguage": "en", + "strings": { + "Waiting for microphone permission": { + "comment": "Inline dictation state while iOS permission prompt is open.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting for microphone permission" + } + } + } + }, + "Recording time": { + "comment": "Accessibility label for elapsed microphone recording time.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recording time" + } + } + } + }, + "Stop": { + "comment": "Stops capture and starts transcription of this draft.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stop" + } + } + } + }, + "Transcribing…": { + "comment": "Inline progress while encrypted audio request is pending.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Transcribing…" + } + } + } + }, + "Finish typing to insert dictation": { + "comment": "Transcription waits until marked input composition is committed.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Finish typing to insert dictation" + } + } + } + }, + "Failed to transcribe audio. Please try again.": { + "comment": "Research microphone/transcription feedback; transient after five seconds.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to transcribe audio. Please try again." + } + } + } + }, + "Retry": { + "comment": "Retries transcription using the existing in-memory recording.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Retry" + } + } + } + }, + "Discard": { + "comment": "Cancels this dictation operation without changing existing draft text or images.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Discard" + } + } + } + }, + "Dictate": { + "comment": "Starts microphone dictation into the owning draft.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dictate" + } + } + } + }, + "Open Settings": { + "comment": "Visible scoped dictation error or its recovery action; never contains server error details.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Settings" + } + } + } + }, + "Dismiss": { + "comment": "Visible scoped dictation error or its recovery action; never contains server error details.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dismiss" + } + } + } + }, + "Microphone access denied. Please enable microphone permissions in Settings > Maple.": { + "comment": "Research microphone/transcription feedback; transient after five seconds.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Microphone access denied. Please enable microphone permissions in Settings > Maple." + } + } + } + }, + "The microphone is unavailable. Try again when another audio operation has finished.": { + "comment": "Visible scoped dictation error or its recovery action; never contains server error details.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The microphone is unavailable. Try again when another audio operation has finished." + } + } + } + }, + "No audio was recorded. Please try again.": { + "comment": "Research microphone/transcription feedback; transient after five seconds.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No audio was recorded. Please try again." + } + } + } + }, + "This recording could not be processed. Record up to five minutes at a time.": { + "comment": "Visible scoped dictation error or its recovery action; never contains server error details.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This recording could not be processed. Record up to five minutes at a time." + } + } + } + }, + "Finish or discard an earlier dictation before starting another.": { + "comment": "Visible scoped dictation error or its recovery action; never contains server error details.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Finish or discard an earlier dictation before starting another." + } + } + } + }, + "Finish the current draft operation before starting dictation.": { + "comment": "Visible scoped dictation error or its recovery action; never contains server error details.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Finish the current draft operation before starting dictation." + } + } + } + }, + "Place the cursor to insert dictation": { + "comment": "Explicit recovery if an input transaction cannot preserve the pending dictation insertion point.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Place the cursor to insert dictation" + } + } + } + }, + "Insert": { + "comment": "Explicit recovery if an input transaction cannot preserve the pending dictation insertion point.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Insert" + } + } + } + }, + "No speech detected. Please try again.": { + "comment": "Dictation result contained no speech; exact Research copy.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No speech detected. Please try again." + } + } + } + } + }, + "version": "1.0" +} diff --git a/apps/maple-ios/Sources/MapleAppUI/Dictation/DictationControls.swift b/apps/maple-ios/Sources/MapleAppUI/Dictation/DictationControls.swift new file mode 100644 index 000000000..331e21ec4 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Dictation/DictationControls.swift @@ -0,0 +1,148 @@ +#if os(iOS) + import MapleAppCore + import SwiftUI + import UIKit + + public struct DictationControls: View { + private let coordinator: DictationCoordinator + private let runtime: ChatRuntime + @Environment(\.openURL) private var openURL + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + public init(coordinator: DictationCoordinator, runtime: ChatRuntime) { + self.coordinator = coordinator; self.runtime = runtime + } + + public var body: some View { + Group { + if let status = coordinator.status(for: runtime) { + HStack(spacing: 12) { + switch status.phase { + case .requestingPermission: + EmptyView() + case .recording: + Image(systemName: "record.circle.fill").foregroundStyle(.red) + Text(Duration.seconds(status.elapsedSeconds), format: .time(pattern: .minuteSecond)) + .monospacedDigit() + .accessibilityLabel(Text("Recording time", tableName: "Dictation", bundle: .module)) + case .transcribing: + Image(systemName: "waveform") + .symbolEffect(.variableColor, isActive: !reduceMotion) + .accessibilityLabel(Text("Transcribing…", tableName: "Dictation", bundle: .module)) + case .waitingForComposition: + Text("Finish typing to insert dictation", tableName: "Dictation", bundle: .module) + case .waitingForInsertionPoint: + Text("Place the cursor to insert dictation", tableName: "Dictation", bundle: .module) + Button(action: { coordinator.insertAtCurrentPosition(runtime: runtime) }) { + Text("Insert", tableName: "Dictation", bundle: .module) + }.accessibilityIdentifier("dictation.insert") + case .failed: + Text( + "Failed to transcribe audio. Please try again.", tableName: "Dictation", + bundle: .module + ) + .accessibilityIdentifier("dictation.failed") + Button(action: { coordinator.retry(runtime: runtime) }) { + Text("Retry", tableName: "Dictation", bundle: .module) + }.accessibilityIdentifier("dictation.retry") + } + Spacer(minLength: 0) + Button(action: { coordinator.cancel(runtime: runtime) }) { + Image(systemName: "xmark") + .frame(minWidth: 44, minHeight: 44) + .contentShape(Rectangle()) + } + .accessibilityLabel(Text("Discard", tableName: "Dictation", bundle: .module)) + .accessibilityIdentifier("dictation.discard") + } + .font(.subheadline) + .accessibilityElement(children: .contain) + .padding(.vertical, 8) + } else if let issue = coordinator.issue(for: runtime) { + VStack(alignment: .leading, spacing: 8) { + issueMessage(issue) + HStack { + if issue == .permissionDenied { + Button(action: { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + openURL(url) + }) { + Text("Open Settings", tableName: "Dictation", bundle: .module) + } + } + Button(action: { coordinator.dismissIssue() }) { + Text("Dismiss", tableName: "Dictation", bundle: .module) + } + } + }.font(.subheadline).accessibilityIdentifier("dictation.error") + } + } + .task(id: coordinator.issueID(for: runtime)) { + guard let id = coordinator.issueID(for: runtime) else { return } + await coordinator.expireIssueAfterDelay(id: id, runtime: runtime) + } + } + + private func issueMessage(_ issue: DictationIssue) -> Text { + switch issue { + case .permissionDenied: + Text( + "Microphone access denied. Please enable microphone permissions in Settings > Maple.", + tableName: "Dictation", + bundle: .module) + case .captureUnavailable: + Text( + "The microphone is unavailable. Try again when another audio operation has finished.", + tableName: "Dictation", bundle: .module) + case .emptyRecording: + Text("No audio was recorded. Please try again.", tableName: "Dictation", bundle: .module) + case .emptyTranscription: + Text("No speech detected. Please try again.", tableName: "Dictation", bundle: .module) + case .recordingTooLarge: + Text( + "This recording could not be processed. Record up to five minutes at a time.", + tableName: "Dictation", bundle: .module) + case .tooManyOperations: + Text( + "Finish or discard an earlier dictation before starting another.", tableName: "Dictation", + bundle: .module) + case .draftUnavailable: + Text( + "Finish the current draft operation before starting dictation.", tableName: "Dictation", + bundle: .module) + case .transcriptionUnavailable, .invalidTranscription: + Text("Failed to transcribe audio. Please try again.", tableName: "Dictation", bundle: .module) + } + } + } + + public struct DictationMicrophoneButton: View { + private let coordinator: DictationCoordinator + private let runtime: ChatRuntime + private let isEnabled: Bool + public init(coordinator: DictationCoordinator, runtime: ChatRuntime, isEnabled: Bool = true) { + self.coordinator = coordinator; self.runtime = runtime; self.isEnabled = isEnabled + } + public var body: some View { + Button(action: { + if recording { coordinator.stop(runtime: runtime) } else { coordinator.start(runtime: runtime) } + }) { + Label { + Text(recording ? "Stop" : "Dictate", tableName: "Dictation", bundle: .module) + } icon: { + Image(systemName: recording ? "stop.fill" : "mic") + } + .frame(minWidth: 44, minHeight: 44) + .contentShape(Rectangle()) + } + .disabled( + !isEnabled + || (!recording + && (coordinator.status(for: runtime) != nil + || runtime.busyDraftIDs.contains(runtime.snapshot.draft.id))) + ) + .accessibilityIdentifier(recording ? "dictation.stop" : "dictation.start") + } + private var recording: Bool { coordinator.status(for: runtime)?.phase == .recording } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Dictation/DictationFixtureView.swift b/apps/maple-ios/Sources/MapleAppUI/Dictation/DictationFixtureView.swift new file mode 100644 index 000000000..ef94b21a3 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Dictation/DictationFixtureView.swift @@ -0,0 +1,253 @@ +#if os(iOS) && DEBUG + import Foundation + import MapleAppCore + import MapleSDK + import Observation + import SwiftUI + import UIKit + + /// Deterministic, synthetic-only UI proof for the shipping Core/input/control + /// surfaces. The fixture neither requests microphone access nor contacts an API. + struct DictationFixtureView: View { + @State private var model = DictationFixtureModel() + @State private var focused = false + @State private var caret = 0 + @State private var reviewing = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text("Dictation controls").font(.title2) + Text( + model.coordinator.status(for: model.runtime)?.phase.rawValue + ?? model.coordinator.statuses.values.first?.phase.rawValue ?? "idle" + ) + .accessibilityIdentifier("dictation.fixture.phase") + Text(model.coordinator.issue(for: model.runtime) == nil ? "none" : "visible") + .accessibilityIdentifier("dictation.fixture.feedback") + Text("Sends \(model.sends)").accessibilityIdentifier("dictation.fixture.sends") + Text("Requests \(model.transcriber.requests); returned \(model.transcriber.returned)") + .accessibilityIdentifier("dictation.fixture.requests") + Text( + model.runtime.queuedTurns.isEmpty + ? "none" + : model.runtime.queuedTurns.map(\.draft.text).joined(separator: " | ") + ) + .accessibilityIdentifier("dictation.fixture.queued") + Text(model.runtime.isEditingQueue ? "editing" : "ordinary") + .accessibilityIdentifier("dictation.fixture.editor-state") + HStack { + Button("Return transcript") { Task { await model.transcriber.finish(failing: false) } } + .accessibilityIdentifier("dictation.fixture.complete") + Button("Fail request") { Task { await model.transcriber.finish(failing: true) } } + .accessibilityIdentifier("dictation.fixture.fail") + }.buttonStyle(.bordered) + Button("Return latest editor transcript") { + Task { await model.transcriber.finish(failing: false, latest: true, text: "late editor text") } + }.accessibilityIdentifier("dictation.fixture.complete-latest") + HStack { + Button("Review queued messages") { reviewing = true } + .accessibilityIdentifier("dictation.fixture.queue") + if model.runtime.isEditingQueue { + Button(MapleStrings.text("Save changes")) { _ = model.runtime.saveQueueEdit() } + .disabled(model.runtime.busyDraftIDs.contains(model.runtime.snapshot.draft.id)) + .accessibilityIdentifier("queue.save") + } + } + HStack { + Button("Deny microphone") { model.capture.permitted = false } + .accessibilityIdentifier("dictation.fixture.deny") + Button("Allow microphone") { model.capture.permitted = true } + .accessibilityIdentifier("dictation.fixture.allow") + }.font(.caption) + editor + HStack { + Button("Cursor at end") { + guard let input = Self.fixtureInput() else { return } + input.selectedTextRange = input.textRange( + from: input.endOfDocument, to: input.endOfDocument) + }.accessibilityIdentifier("dictation.fixture.cursor-end") + Text("Caret \(caret)").accessibilityIdentifier("dictation.fixture.caret") + }.font(.caption) + DictationControls(coordinator: model.coordinator, runtime: model.runtime) + HStack { + DictationMicrophoneButton(coordinator: model.coordinator, runtime: model.runtime) + .labelStyle(.iconOnly).buttonStyle(.plain) + Button("Send") { model.sends += 1 } + .disabled(!model.runtime.canSend).accessibilityIdentifier("dictation.fixture.send") + }.buttonStyle(.bordered) + HStack { + Button("Mark text") { + Self.fixtureInput()?.setMarkedText("にほん", selectedRange: NSRange(location: 3, length: 0)) + } + .accessibilityIdentifier("dictation.fixture.mark") + Button("Edit queued A") { + if let first = model.runtime.queuedTurns.first { + _ = model.runtime.beginQueueEdit(id: first.id) + } + }.accessibilityIdentifier("dictation.fixture.edit") + Button("Cancel edit") { model.runtime.cancelQueueEdit() } + .accessibilityIdentifier("dictation.fixture.cancel-edit") + }.font(.caption) + Text(model.runtime.snapshot.queue?.edit?.heldDraft.text ?? "No held draft") + .accessibilityIdentifier("dictation.fixture.held") + }.padding() + } + .sheet(isPresented: $reviewing) { QueueReview(runtime: model.runtime) { focused = true } } + .onDisappear { + model.coordinator.invalidate(); model.runtime.invalidate(); model.transcriber.tearDown() + } + } + + private var editor: some View { + let runtime = model.runtime, owner = runtime.snapshot.draft.id + return ComposerEditor( + text: Binding( + get: { runtime.ownedDraft(id: owner)?.text ?? "" }, + set: { value in + guard var draft = runtime.ownedDraft(id: owner) else { return } + draft.text = value; _ = runtime.updateOwnedDraft(draft) + }), focused: $focused, ownerID: owner, + placeholder: "Synthetic draft", accessibilityIdentifier: "dictation.fixture.composer", + externalSelection: model.coordinator.editorState(runtime: runtime, draftID: owner), + onChange: { state, edits in + caret = state.selectionUTF16.location + model.coordinator.updateEditor(runtime: runtime, draftID: owner, state: state, edits: edits) + }, + onSubmit: { + guard runtime.snapshot.draft.id == owner else { return } + if runtime.canSend { model.sends += 1 } + } + ) + .frame(minHeight: 72).padding(8) + .background(.secondary.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) + } + + // Debug-only input probe uses public UITextInput APIs. Shipping code owns + // its UITextView directly and does not inspect the SwiftUI hierarchy. + private static func fixtureInput() -> (any UITextInput)? { + func find(_ view: UIView) -> (any UITextInput)? { + if view.isFirstResponder, let input = view as? any UITextInput { return input } + for child in view.subviews { if let input = find(child) { return input } } + return nil + } + for scene in UIApplication.shared.connectedScenes { + guard let scene = scene as? UIWindowScene else { continue } + for window in scene.windows where window.isKeyWindow { + if let input = find(window) { return input } + } + } + return nil + } + } + + @MainActor @Observable private final class DictationFixtureModel { + let runtime: ChatRuntime + let capture: FixtureMicrophone + let transcriber: FixtureTranscriber + let coordinator: DictationCoordinator + var sends = 0 + init() { + let scope = AccountScope(apiOrigin: "http://127.0.0.1:1", principalID: "synthetic-dictation") + let runtime = ChatRuntime( + scope: scope, service: FixtureChatService(), persistence: FixtureDraftStore(), + conversationID: "synthetic-chat") + let capture = FixtureMicrophone(), transcriber = FixtureTranscriber() + let coordinator = DictationCoordinator(scope: scope, capture: capture, transcriber: transcriber) + self.runtime = runtime; self.capture = capture; self.transcriber = transcriber; + self.coordinator = coordinator + runtime.onDraftOwnerChanging = { [weak coordinator, weak runtime] id in + guard let runtime else { return }; coordinator?.ownerWillChange(runtime: runtime, draftID: id) + } + runtime.onDraftOwnerRetired = { [weak coordinator, weak runtime] id in + guard let runtime else { return }; coordinator?.ownerRetired(runtime: runtime, draftID: id) + } + runtime.draftText = "Queued A"; _ = runtime.enqueueDraft() + runtime.draftText = "Keep this draft" + } + } + + @MainActor private final class FixtureMicrophone: DictationCapturing { + var permitted = true + var owner: UUID? + func requestPermission() async -> Bool { permitted } + func start(id: UUID, maximumFrames: Int, onLimit: @escaping @MainActor @Sendable () -> Void) throws { + owner = id + } + func stop(id: UUID) throws -> Data { + guard owner == id else { throw DictationIssue.captureUnavailable } + owner = nil; return Data(repeating: 0, count: 320) + } + func cancel(id: UUID) { if owner == id { owner = nil } } + } + + @MainActor @Observable private final class FixtureTranscriber: DictationTranscribing { + @ObservationIgnored private var pending: [Int: CheckedContinuation] = [:] + @ObservationIgnored private var nextResult: (failing: Bool, text: String)? + private(set) var requests = 0 + private(set) var returned = 0 + + func transcribe(wav: Data) async throws -> String { + requests += 1 + let id = requests + if let nextResult { + self.nextResult = nil; returned += 1 + return try result(failing: nextResult.failing, text: nextResult.text) + } + // Deliberately ignore task cancellation: late service callbacks must + // be rejected by the real account/draft coordinator, not the fixture. + return try await withCheckedThrowingContinuation { pending[id] = $0 } + } + func finish(failing: Bool, latest: Bool = false, text: String = "spoken text") async { + let id = latest ? pending.keys.max() : pending.keys.min() + guard let id, let continuation = pending.removeValue(forKey: id) else { + if !latest { nextResult = (failing, text) } + return + } + returned += 1 + if failing { + continuation.resume(throwing: DictationIssue.transcriptionUnavailable) + } else { + continuation.resume(returning: text) + } + } + func tearDown() { + let previous = pending.values; pending = [:]; nextResult = nil + for continuation in previous { continuation.resume(throwing: CancellationError()) } + } + private func result(failing: Bool, text: String) throws -> String { + if failing { throw DictationIssue.transcriptionUnavailable }; return text + } + } + + private actor FixtureDraftStore: ChatPersistence { + private var snapshots: [String: ChatSnapshot] = [:] + func load(chatID: String, scope: AccountScope) -> ChatSnapshot? { snapshots[chatID] } + func loadAll(scope: AccountScope) -> [ChatSnapshot] { Array(snapshots.values) } + func save(_ snapshot: ChatSnapshot, scope: AccountScope) { snapshots[snapshot.storageKey] = snapshot } + func remove(chatID: String, scope: AccountScope) { snapshots[chatID] = nil } + func rekey(from: String, to: ChatSnapshot, scope: AccountScope) { + snapshots[from] = nil; snapshots[to.storageKey] = to + } + } + + private struct FixtureChatService: ChatServing { + func createConversation(metadata: [String: JSONValue]?, projectId: String?, pinned: Bool?) async throws + -> Conversation + { throw URLError(.unsupportedURL) } + func listConversations(_ parameters: ConversationsListParameters) async throws -> APIPage { + APIPage(data: []) + } + func createResponse(_ request: ResponsesCreateRequest, maximumInferenceSends: Int) async throws + -> AsyncThrowingStream + { throw URLError(.unsupportedURL) } + func retrieveResponse(id: String) async throws -> ResearchResponse { throw URLError(.unsupportedURL) } + func cancelResponse(id: String) async throws -> ResearchResponse { throw URLError(.unsupportedURL) } + func getConversationItem(conversationId: String, itemId: String) async throws -> ConversationItem { + throw URLError(.unsupportedURL) + } + func listConversationItems(conversationId: String, parameters: ListParameters) async throws -> APIPage< + ConversationItem + > { APIPage(data: []) } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Images/ImageAttachmentViews.swift b/apps/maple-ios/Sources/MapleAppUI/Images/ImageAttachmentViews.swift new file mode 100644 index 000000000..d64fa35aa --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Images/ImageAttachmentViews.swift @@ -0,0 +1,261 @@ +#if os(iOS) + import CoreTransferable + import ImageIO + import MapleAppCore + import PhotosUI + import SwiftUI + import UniformTypeIdentifiers + + private struct ImportedPhoto: Transferable, Sendable { + let data: Data + static var transferRepresentation: some TransferRepresentation { + FileRepresentation(importedContentType: .image) { file in + ImportedPhoto(data: try ImageImportProcessor.readSelection(file.file)) + } + } + } + + @MainActor final class ClipboardImageSource { + let provider: NSItemProvider + init(provider: NSItemProvider) { self.provider = provider } + + static func importImages(_ providers: [NSItemProvider], using importer: ImageImportController, draftID: UUID) { + guard providers.count <= 10 else { importer.report(.attachmentLimit); return } + importer.importImages( + providers.map { provider in + let source = ClipboardImageSource(provider: provider) + return SelectedImageSource { try await source.read() } + }, draftID: draftID) + } + + func read() async throws -> Data { + guard + let type = provider.registeredTypeIdentifiers.first(where: { UTType($0)?.conforms(to: .image) == true }) + else { + throw ImageImportIssue.unsupported + } + return try await withCheckedThrowingContinuation { continuation in + // The selected provider owns this temporary URL only until its + // callback returns, so consume its bounded file in that callback. + _ = provider.loadFileRepresentation(forTypeIdentifier: type) { url, _ in + guard let url else { continuation.resume(throwing: ImageImportIssue.unavailable); return } + do { continuation.resume(returning: try ImageImportProcessor.readSelection(url)) } catch { + continuation.resume(throwing: ImageImportIssue.unavailable) + } + } + } + } + } + + private struct ImportOwner: Identifiable { let id: UUID } + + struct ImageAttachmentMenu: View { + @Bindable var importer: ImageImportController + let access: AccountAccess + let onUnavailable: () -> Void + @State private var photos = false + @State private var files = false + @State private var picked: [PhotosPickerItem] = [] + @State private var selectionOwner: UUID? + @State private var cameraOwner: ImportOwner? + @State private var pasteOwner: ImportOwner? + @Environment(\.compactMenuPresentationDismissed) private var compactMenuPresentationDismissed + + var body: some View { + Menu { + Button(MapleStrings.text("Photo library"), systemImage: "photo.on.rectangle") { + select { photos = true } + } + Button(MapleStrings.text("Take photo"), systemImage: "camera") { + select { + if UIImagePickerController.isSourceTypeAvailable(.camera) { + cameraOwner = ImportOwner(id: importer.runtime.snapshot.draft.id) + } else { + importer.report(.unavailable) + } + } + } + Button(MapleStrings.text("Files"), systemImage: "folder") { select { files = true } } + Button(MapleStrings.text("Paste image"), systemImage: "document.on.clipboard") { + select { pasteOwner = ImportOwner(id: importer.runtime.snapshot.draft.id) } + } + } label: { + Image(systemName: "plus") + } + .frame(minWidth: 44, minHeight: 44) + .accessibilityLabel(MapleStrings.text("Attach image")).accessibilityIdentifier("chat.attach") + .accessibilityValue(access == .allowed ? "" : MapleStrings.text("Unavailable")) + .tracksChatPresentation(photos || files || pasteOwner != nil || cameraOwner != nil) + .photosPicker( + isPresented: $photos, selection: $picked, + maxSelectionCount: max(1, 10 - importer.runtime.snapshot.draft.images.count), + matching: .images, preferredItemEncoding: .current + ) + .onChange(of: picked) { _, values in + guard !values.isEmpty, let owner = selectionOwner else { return } + picked = [] + importer.importImages( + values.map { item in + SelectedImageSource { + guard let image = try await item.loadTransferable(type: ImportedPhoto.self) else { + throw ImageImportIssue.unavailable + } + return image.data + } + }, draftID: owner) + } + .fileImporter( + isPresented: $files, allowedContentTypes: [.png, .jpeg, .webP, .heic, .heif], + allowsMultipleSelection: true + ) { result in + guard let owner = selectionOwner else { return } + switch result { + case .success(let urls): + importer.importImages( + urls.map { url in + SelectedImageSource { + try await Task.detached(priority: .userInitiated) { + try ImageImportProcessor.readSelection(url) + }.value + } + }, draftID: owner) + case .failure: importer.report(.unavailable) + } + } + .sheet(item: $pasteOwner, onDismiss: compactMenuPresentationDismissed) { owner in + NavigationStack { + VStack(spacing: 16) { + Text(MapleStrings.text("Paste an image from the clipboard.")) + PasteButton(supportedContentTypes: [.image]) { providers in + ClipboardImageSource.importImages(providers, using: importer, draftID: owner.id) + pasteOwner = nil + }.tint(MapleAccent.nativeFilled).accessibilityIdentifier("images.paste") + }.padding() + .navigationTitle(MapleStrings.text("Paste image")) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(MapleStrings.text("Cancel")) { pasteOwner = nil } + } + } + }.presentationDetents([.medium]) + } + .chatFullScreenCover(item: $cameraOwner) { owner in + CameraCaptureView { image, orientation in + if let image { + importer.importImages( + [ + SelectedImageSource { + try await ImageCaptureEncoder.shared.encode(image, orientation: orientation) + } + ], draftID: owner.id) + } + cameraOwner = nil + } + } + + } + private func select(_ action: () -> Void) { + guard access == .allowed else { onUnavailable(); return } + guard importer.runtime.snapshot.draft.images.count < 10 else { importer.report(.attachmentLimit); return } + selectionOwner = importer.runtime.snapshot.draft.id; action() + } + } + + private actor ImageCaptureEncoder { + static let shared = ImageCaptureEncoder() + func encode(_ image: CGImage, orientation: UInt32) throws -> Data { + try Task.checkCancellation() + guard image.width > 0, image.height > 0, image.width <= 100_000, image.height <= 100_000, + Double(image.width) * Double(image.height) <= 100_000_000 + else { throw ImageImportIssue.tooLarge } + let edge = max(image.width, image.height) + let factor = min(1, 4_096.0 / Double(edge)) + let width = max(1, Int(Double(image.width) * factor)), height = max(1, Int(Double(image.height) * factor)) + guard + let context = CGContext( + data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) + else { throw ImageImportIssue.invalidImage } + context.interpolationQuality = .high + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + guard let reduced = context.makeImage() else { throw ImageImportIssue.invalidImage } + try Task.checkCancellation() + let data = NSMutableData() + guard let output = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else { + throw ImageImportIssue.unavailable + } + CGImageDestinationAddImage( + output, reduced, + [ + kCGImageDestinationLossyCompressionQuality: 0.92, + kCGImagePropertyOrientation: orientation, + ] as CFDictionary) + guard CGImageDestinationFinalize(output), data.length <= ImageImportProcessor.maximumInputBytes else { + throw ImageImportIssue.tooLarge + } + return data as Data + } + } + + /// Camera capture has no SwiftUI camera view. This adapter only returns an + /// immutable captured image; encoding and validation stay off the UI actor. + private struct CameraCaptureView: UIViewControllerRepresentable { + let completed: (CGImage?, UInt32) -> Void + func makeCoordinator() -> Coordinator { Coordinator(completed: completed) } + func makeUIViewController(context: Context) -> UIImagePickerController { + let picker = UIImagePickerController(); picker.sourceType = .camera + picker.mediaTypes = [UTType.image.identifier]; picker.delegate = context.coordinator + return picker + } + func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {} + final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + let completed: (CGImage?, UInt32) -> Void + init(completed: @escaping (CGImage?, UInt32) -> Void) { self.completed = completed } + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { completed(nil, 1) } + func imagePickerController( + _ picker: UIImagePickerController, + didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] + ) { + guard let image = info[.originalImage] as? UIImage, let bitmap = image.cgImage else { + completed(nil, 1); return + } + let orientation: CGImagePropertyOrientation + switch image.imageOrientation { + case .up: orientation = .up + case .down: orientation = .down + case .left: orientation = .left + case .right: orientation = .right + case .upMirrored: orientation = .upMirrored + case .downMirrored: orientation = .downMirrored + case .leftMirrored: orientation = .leftMirrored + case .rightMirrored: orientation = .rightMirrored + @unknown default: orientation = .up + } + completed(bitmap, orientation.rawValue) + } + } + } + + struct ImageImportIssueView: View { + let issue: ImageImportIssue + var body: some View { + Text(label).font(.footnote).foregroundStyle(MapleRedText()).accessibilityIdentifier("images.error") + } + private var label: String { + switch issue { + case .tooLarge: MapleStrings.text("This image is too large. Choose an image smaller than 20 MB.") + case .attachmentLimit: + MapleStrings.text( + "This selection exceeds your current attachment limits. Existing images are unchanged.") + case .accountImageLimit: + MapleStrings.text("Chat drafts and queued messages can retain up to 640 images across your account") + case .accountAttachmentLimit: + MapleStrings.text("Chat drafts and queued attachments can use up to 256 MiB across your account") + case .unsupported, .invalidImage: + MapleStrings.text("This image could not be read. Choose a PNG, JPEG, WebP, or HEIF image.") + case .retired: MapleStrings.text("The draft for this image is no longer open.") + case .unavailable: MapleStrings.text("The image could not be imported. Your existing draft is unchanged.") + } + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Images/ImagePreviews.swift b/apps/maple-ios/Sources/MapleAppUI/Images/ImagePreviews.swift new file mode 100644 index 000000000..1e843d0dc --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Images/ImagePreviews.swift @@ -0,0 +1,257 @@ +import CoreGraphics +import Foundation +import ImageIO +import MapleAppCore +import UniformTypeIdentifiers + +/// History images can originate in another client. Validate their encoded +/// dimensions before asking ImageIO to allocate even a downsampled raster. +enum ImagePreviewDecoding { + static func thumbnail(_ data: Data, edge: Int) throws -> CGImage { + try Task.checkCancellation() + guard !data.isEmpty, data.count <= ImageImportProcessor.maximumInputBytes else { + throw ImageImportIssue.tooLarge + } + let allowedTypes = [ + UTType.png.identifier, UTType.jpeg.identifier, UTType.webP.identifier, + UTType.gif.identifier, UTType.heic.identifier, UTType.heif.identifier, + UTType(mimeType: "image/avif")?.identifier, + ].compactMap { $0 } + guard + let source = CGImageSourceCreateWithData( + data as CFData, [kCGImageSourceShouldCache: false] as CFDictionary), + CGImageSourceGetStatus(source) == .statusComplete, + CGImageSourceGetCount(source) >= 1, + CGImageSourceGetStatusAtIndex(source, 0) == .statusComplete, + let type = CGImageSourceGetType(source) as String?, allowedTypes.contains(type), + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let width = properties[kCGImagePropertyPixelWidth] as? NSNumber, + let height = properties[kCGImagePropertyPixelHeight] as? NSNumber + else { throw ImageImportIssue.invalidImage } + try validateDimensions(width: width.doubleValue, height: height.doubleValue) + try Task.checkCancellation() + // Animated history attachments deliberately display only the first frame. + guard + let image = CGImageSourceCreateThumbnailAtIndex( + source, 0, + [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: min(4_096, max(1, edge)), + kCGImageSourceShouldCacheImmediately: true, + ] as CFDictionary) + else { throw ImageImportIssue.invalidImage } + try Task.checkCancellation() + return image + } + + static func validateDimensions(width: Double, height: Double) throws { + guard width.isFinite, height.isFinite, width >= 1, height >= 1, + width <= 100_000, height <= 100_000, width * height <= 100_000_000 + else { throw ImageImportIssue.tooLarge } + } +} + +#if os(iOS) + import MapleSDK + import SwiftUI + + private actor PreviewDecoder { + static let shared = PreviewDecoder() + func decode(_ data: Data, edge: Int) throws -> CGImage { + try ImagePreviewDecoding.thumbnail(data, edge: edge) + } + func inlineImage(_ value: JSONValue) throws -> ChatImageAttachment { + let source: String + switch value { + case .string(let text): source = text + case .object(let object): + guard case .string(let text) = object["url"] else { throw ImageImportIssue.invalidImage } + source = text + default: throw ImageImportIssue.invalidImage + } + guard source.utf8.count <= 28 * 1_024 * 1_024, + let separator = source.firstIndex(of: ",") + else { throw ImageImportIssue.tooLarge } + let prefix = String(source[.. 1 { state = value.translation } + }.onEnded { value in + offset = clamped( + CGSize( + width: offset.width + value.translation.width, + height: offset.height + value.translation.height), in: geometry.size, + scale: scale) + } + ) + .accessibilityLabel(MapleStrings.text("Attached image")) + .accessibilityAction(named: MapleStrings.text("Zoom in")) { scale = min(6, scale + 1) } + .accessibilityAction(named: MapleStrings.text("Zoom out")) { + scale = max(1, scale - 1); offset = .zero + } + } else if failed { + Text(MapleStrings.text("Image unavailable")) + } else { + Image(systemName: "photo").foregroundStyle(.secondary).accessibilityHidden(true) + } + }.clipped() + } + .navigationTitle(MapleStrings.text("Image")).navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(MapleStrings.text("Close image"), systemImage: "xmark") { dismiss() } + .accessibilityIdentifier( + "images.close") + } + } + } + .task { + do { + let value = try await PreviewDecoder.shared.decode(attachment.data, edge: 4_096) + try Task.checkCancellation(); image = value + } catch { if !Task.isCancelled { failed = true } } + } + } + private func clamped(_ value: CGSize, in bounds: CGSize, scale: CGFloat) -> CGSize { + let x = max(0, bounds.width * (scale - 1) / 2), y = max(0, bounds.height * (scale - 1) / 2) + return CGSize(width: min(x, max(-x, value.width)), height: min(y, max(-y, value.height))) + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Library/HistoryView.swift b/apps/maple-ios/Sources/MapleAppUI/Library/HistoryView.swift new file mode 100644 index 000000000..547025895 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Library/HistoryView.swift @@ -0,0 +1,759 @@ +#if os(iOS) + import MapleAppCore + import MapleSDK + import SwiftUI + + #if DEBUG + /// A synthetic list host exercises the shipping selection and deletion controls. + /// The application itself presents chats only in its Library menu. + struct HistoryView: View { + @Bindable var library: LibraryModel + let onSelectChat: @MainActor (String) -> Void + @State private var selecting = false + var body: some View { + List { + ForEach(library.visibleConversations, id: \.id) { conversation in + LibrarySelectableConversationRow( + conversation: conversation, library: library, selectionLibrary: library, + selecting: $selecting, onSelect: onSelectChat) + } + if library.hasMore { + Button { + Task { await library.loadMore() } + } label: { + libraryText("Load more") + } + .disabled(library.isLoading) + } + if !library.isLoading, library.conversations.isEmpty, library.issue == nil { + libraryText("No chats yet") + } + if let issue = library.issue { LibraryIssueView(issue: issue) } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .background(MapleSurface.sidebar) + .accessibilityIdentifier("library.fixture.chat-list") + .modifier(LibraryChatManagement(library: library, selecting: $selecting)) + .task { await library.load(.all) } + .refreshable { await library.load(.all) } + } + } + #endif + + struct LibrarySelectableConversationRow: View { + let conversation: Conversation + @Bindable var library: LibraryModel + @Bindable var selectionLibrary: LibraryModel + @Binding var selecting: Bool + let onSelect: @MainActor (String) -> Void + var onMutation: (@MainActor () async -> Void)? = nil + var leadingInset: CGFloat = 20 + var dragContext: LibraryChatDragContext? = nil + var dragEnabled = true + var dropDestination: LibraryConversationDestination? = nil + var selectedChatID: String? = nil + var activity: ConversationActivity = .idle + var body: some View { + Group { + if selecting { + Button { + if selectionLibrary.selectedIDs.contains(conversation.id) { + selectionLibrary.selectedIDs.remove(conversation.id) + } else { + selectionLibrary.selectedIDs.insert(conversation.id) + } + } label: { + HStack(spacing: 12) { + Image( + systemName: selectionLibrary.selectedIDs.contains(conversation.id) + ? "checkmark.circle.fill" : "circle") + ConversationTitle( + conversation: conversation, + emphasized: selectionLibrary.selectedIDs.contains(conversation.id), activity: activity) + Spacer(minLength: 0) + } + .frame(minHeight: 50).contentShape(Rectangle()).foregroundStyle(.primary) + } + .buttonStyle(.plain) + .accessibilityIdentifier("library.chat." + conversation.id) + .accessibilityLabel( + conversationActivityLabel(title: displayedTitle(conversation), activity: activity) + ) + .accessibilityAddTraits(selectionLibrary.selectedIDs.contains(conversation.id) ? [.isSelected] : []) + .disabled(library.isMutating || selectionLibrary.isMutating) + } else { + ConversationLibraryRow( + conversation: conversation, library: library, onSelect: onSelect, onMutation: onMutation, + onSelectForSelection: { + selectionLibrary.selectedIDs.insert(conversation.id); selecting = true + }, leadingInset: leadingInset, contextMenuEnabled: dragContext == nil || !dragEnabled, + selectedChatID: selectedChatID, activity: activity) + } + } + .modifier( + LibraryChatDragSource( + context: dragContext, library: library, id: conversation.id, + enabled: dragEnabled && !selecting && !selectionLibrary.isMutating) + ) + .modifier( + LibraryChatDropTarget( + context: dragContext, library: selectionLibrary, destination: dropDestination, + enabled: dragEnabled && !selecting, onMutation: onMutation) + ) + .listRowInsets(.vertical, 0) + .listRowInsets(.leading, leadingInset).listRowInsets(.trailing, 12) + .listRowSeparatorTint(MapleSurface.border.opacity(0.28)) + .listRowSeparator(.visible).listRowBackground(Color.clear) + } + } + + struct LibraryChatManagement: ViewModifier { + @Bindable var library: LibraryModel + @Binding var selecting: Bool + var onMutation: (@MainActor () async -> Void)? = nil + var account: AccountEntitlements? = nil + var onSettings: (@MainActor () -> Void)? = nil + var showsWordmarkMenu = true + var compactRootBar = false + var nativeRootBarHidden = false + var nativeRootBarTrailing: AnyView? = nil + var selectionDeleteTitle = "Delete selected chats" + @Environment(\.colorScheme) private var colorScheme + @Environment(\.displayScale) private var displayScale + @State private var moving = false + @State private var deleting = false + @State private var deletingAll = false + func body(content: Content) -> some View { + content + .modifier(LibraryChatDeletionConfirmation()) + .toolbar { + if !compactRootBar { + if showsWordmarkMenu { + ToolbarItem(placement: .principal) { mapleMenu } + .sharedBackgroundVisibility(.hidden) + } + if selecting { + ToolbarItem(placement: .topBarLeading) { selectionDone } + } + } + } + .modifier( + LibraryCompactRootBar( + enabled: compactRootBar, hidden: nativeRootBarHidden, + leading: selecting ? AnyView(selectionDone) : nil, + principal: AnyView(mapleMenu), trailing: nativeRootBarTrailing) + ) + .safeAreaInset(edge: .bottom) { + if selecting { + VStack(spacing: 8) { + Button { + moving = true + } label: { + Label { + libraryText("Move selected chats") + } icon: { + Image(systemName: "folder") + } + .frame(maxWidth: .infinity, minHeight: 44) + } + Button(role: .destructive) { + deleting = true + } label: { + Label { + libraryText(selectionDeleteTitle) + } icon: { + Image(systemName: "trash") + } + .frame(maxWidth: .infinity, minHeight: 44) + } + } + .padding(8).background(MapleSurface.sidebar) + .disabled(library.selectedIDs.isEmpty || library.isMutating) + } + } + .confirmationDialog( + Text( + String( + localized: "Delete %lld chats?", defaultValue: "Delete \(library.selectedIDs.count) chats?", + table: "Library", bundle: .module)), isPresented: $deleting, + titleVisibility: .visible + ) { + Button(role: .destructive) { + let ids = library.selectedIDs + Task { + if await library.deleteConversations(ids: ids) { + selecting = false; library.selectedIDs = [] + await onMutation?() + } + } + } label: { + libraryText("Delete selected chats") + } + } message: { + Text( + String( + localized: + "This will permanently delete %lld selected chats. This action cannot be undone.", + defaultValue: + "This will permanently delete \(library.selectedIDs.count) selected chats. This action cannot be undone.", + table: "Library", bundle: .module)) + } + .sheet(isPresented: $moving, onDismiss: { Task { await onMutation?() } }) { + NavigationStack { + MoveChatsView(library: library, ids: library.selectedIDs) { + selecting = false; library.selectedIDs = [] + } + } + } + .sheet(isPresented: $deletingAll) { + NavigationStack { + DeleteLibraryContentView( + title: "Delete all chats", + explanation: libraryText( + "This deletes every chat in this account, including chats in projects. Projects remain." + ) + ) { + await library.deleteAllConversations() + let issue = library.issue + await onMutation?() + return issue + } + } + } + } + + private var mapleMenu: some View { + Menu { + Section { + if let onSettings { + Button(action: onSettings) { + Label { + libraryText("Settings") + } icon: { + settingsIcon + } + } + .accessibilityIdentifier("library.settings") + .accessibilityLabel(settingsAccessibilityLabel) + .accessibilityHint(settingsAttentionHint) + } + Button { + selecting.toggle(); if !selecting { library.selectedIDs = [] } + } label: { + Label { + libraryText(selecting ? "Done selecting" : "Select chats") + } icon: { + Image(systemName: selecting ? "checkmark" : "checkmark.circle") + } + } + } + Section { + Button(role: .destructive) { + deletingAll = true + } label: { + Label { + libraryText("Delete all chats") + } icon: { + Image(systemName: "trash") + } + } + } + } label: { + MapleWordmark().frame(width: 103, height: 20) + .overlay(alignment: .trailing) { + Image(systemName: "chevron.down").font(.caption2.weight(.semibold)) + .foregroundStyle(MapleAccent.action).offset(x: 29).accessibilityHidden(true) + } + .padding(.horizontal, 30) + .frame(minHeight: 44).contentShape(Rectangle()) + } + .menuStyle(.button) + .buttonStyle(.plain) + .frame(minHeight: 44) + .accessibilityLabel(libraryText("Maple menu")) + .accessibilityIdentifier("library.actions") + .disabled(library.isMutating) + } + + private var settingsAccessibilityLabel: Text { + guard let attention = account.flatMap({ LibraryAccountAttention(team: $0.team.value) }) else { + return libraryText("Settings") + } + return Text( + String( + localized: "Settings, %@", defaultValue: "Settings, \(attention.accessibilityLabel)", + table: "Library", bundle: .module)) + } + + private var settingsAttentionHint: Text { + guard let attention = account.flatMap({ LibraryAccountAttention(team: $0.team.value) }) else { + return Text(verbatim: "") + } + return Text(verbatim: attention.accessibilityLabel) + } + + private var settingsIcon: Image { + guard let account, LibraryAccountAttention(team: account.team.value) != nil else { + return Image(systemName: "gearshape") + } + // Native menus export one title and image from Label. Render the + // existing badge into that image so its color survives menu export. + let renderer = ImageRenderer( + content: Image(systemName: "gearshape").font(.system(size: 19)).foregroundStyle(.primary) + .frame(width: 24, height: 24) + .overlay(alignment: .topTrailing) { + LibraryAccountAttentionBadge(account: account).font(.system(size: 10)) + .background(.background, in: Circle()).offset(x: 3, y: -3) + } + .padding(3).environment(\.colorScheme, colorScheme)) + renderer.scale = displayScale + guard let image = renderer.uiImage else { return Image(systemName: "exclamationmark.circle.fill") } + return Image(uiImage: image).renderingMode(.original) + } + + private var selectionDone: some View { + Button { + selecting = false; library.selectedIDs = [] + } label: { + libraryText("Done") + } + .accessibilityIdentifier("library.selection-done") + .disabled(library.isMutating) + } + } + + private struct LibraryCompactRootBar: ViewModifier { + let enabled: Bool + let hidden: Bool + let leading: AnyView? + let principal: AnyView + let trailing: AnyView? + + func body(content: Content) -> some View { + if enabled { + content.compactMenuRootBar( + role: .library, hidden: hidden, leading: leading, principal: principal, trailing: trailing) + } else { + content + } + } + } + + struct ConversationTitle: View { + let conversation: Conversation + var emphasized = false + var activity: ConversationActivity = .idle + var body: some View { + let title = LibraryModel.title(conversation) + HStack(spacing: 6) { + if conversation.pinned { + Image(systemName: "pin").font(.caption).accessibilityHidden(true) + } + Group { + if title.isEmpty { libraryText("Untitled chat") } else { Text(verbatim: title) } + } + .fontWeight(emphasized ? .bold : .regular) + .lineLimit(1).truncationMode(.tail) + ConversationActivityIndicator(activity: activity) + } + } + } + + struct ConversationActivityIndicator: View { + let activity: ConversationActivity + @ViewBuilder var body: some View { + switch activity { + case .idle: EmptyView() + case .running: + ProgressView().controlSize(.mini).tint(MapleAccent.action).accessibilityHidden(true) + case .unread: + Circle().fill(.green).frame(width: 8, height: 8).accessibilityHidden(true) + } + } + } + + @MainActor private func displayedTitle(_ conversation: Conversation) -> String { + let title = LibraryModel.title(conversation) + return title.isEmpty ? String(localized: "Untitled chat", table: "Library", bundle: .module) : title + } + + func conversationActivityLabel(title: String, activity: ConversationActivity) -> Text { + switch activity { + case .idle: Text(verbatim: title) + case .running: + Text(String(localized: "%@, running", defaultValue: "\(title), running", table: "Library", bundle: .module)) + case .unread: + Text( + String( + localized: "%@, completed, unread", defaultValue: "\(title), completed, unread", table: "Library", + bundle: .module)) + } + } + + /// Like the project name form, rename remains open on validation or + /// service failure; a dismissed form cannot apply a later completion. + private struct RenameChatForm: View { + let conversation: Conversation + @Bindable var library: LibraryModel + let onMutation: (@MainActor () async -> Void)? + @Environment(\.dismiss) private var dismiss + @State private var name = "" + @State private var validation: String? + @State private var submitting = false + @State private var visible = false + @FocusState private var focused: Bool + + var body: some View { + Form { + libraryText("Enter a new name for this chat conversation.") + TextField(text: $name) { libraryText("Chat Title") } + .focused($focused).submitLabel(.done).onSubmit { submit() } + .accessibilityIdentifier("library.rename-title") + if let validation { + libraryText(validation).foregroundStyle(MapleRedText()) + .accessibilityIdentifier("library.rename-validation") + } + if let issue = library.issue { LibraryIssueView(issue: issue) } + } + .scrollContentBackground(.hidden).background(MapleSurface.canvas) + .navigationTitle(libraryText("Rename Chat")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button { + dismiss() + } label: { + libraryText("Cancel") + }.disabled(submitting) + } + ToolbarItem(placement: .confirmationAction) { + Button(action: submit) { libraryText("Rename Chat") } + .disabled(submitting || library.isMutating || !library.isActive) + .accessibilityIdentifier("library.rename-save") + } + } + .interactiveDismissDisabled(submitting) + .disabled(submitting || !library.isActive) + .onAppear { + visible = true; name = LibraryModel.title(conversation); focused = true + } + .onDisappear { visible = false } + .onChange(of: name) { _, _ in validation = nil } + } + + private func submit() { + guard visible, library.isActive, !submitting, !library.isMutating else { return } + let title = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !title.isEmpty else { validation = "Chat title cannot be empty."; return } + guard title != LibraryModel.title(conversation).trimmingCharacters(in: .whitespacesAndNewlines) else { + validation = "Please enter a different title."; return + } + validation = nil; submitting = true; focused = false + Task { @MainActor in + await library.renameConversation(id: conversation.id, title: title) + guard visible, library.isActive else { return } + submitting = false + if library.issue == nil { await onMutation?(); if visible, library.isActive { dismiss() } } + } + } + } + + /// A swipe cell can be recycled as its actions close. Keep confirmation on + /// the list host, and capture the exact account owner before presenting it. + @MainActor fileprivate struct LibraryChatDeletionRequest { + let id: String + let title: String + let library: LibraryModel + let onMutation: (@MainActor () async -> Void)? + } + + private struct LibraryChatDeletionRequestKey: EnvironmentKey { + static let defaultValue: @MainActor @Sendable (LibraryChatDeletionRequest) -> Void = { _ in } + } + + extension EnvironmentValues { + fileprivate var requestChatDeletion: @MainActor @Sendable (LibraryChatDeletionRequest) -> Void { + get { self[LibraryChatDeletionRequestKey.self] } + set { self[LibraryChatDeletionRequestKey.self] = newValue } + } + } + + struct LibraryChatDeletionConfirmation: ViewModifier { + @State private var pending: LibraryChatDeletionRequest? + func body(content: Content) -> some View { + content + .environment(\.requestChatDeletion) { request in + guard pending == nil, request.library.isActive, !request.library.isMutating else { return } + pending = request + } + .alert( + Text("Are you sure you want to delete this chat?", tableName: "Library", bundle: .module), + isPresented: Binding( + get: { pending != nil }, set: { if !$0 { pending = nil } }), + presenting: pending + ) { request in + Button(role: .cancel) { + pending = nil + } label: { + libraryText("Cancel") + } + Button(role: .destructive) { + pending = nil + Task { + guard request.library.isActive else { return } + await request.library.deleteConversations(ids: [request.id]) + if request.library.isActive, request.library.issue == nil { await request.onMutation?() } + } + } label: { + libraryText("Delete") + } + } message: { request in + Text( + String( + localized: "This will permanently delete \"%@\". This action cannot be undone.", + defaultValue: + "This will permanently delete \"\(request.title)\". This action cannot be undone.", + table: "Library", bundle: .module)) + } + .onChange(of: pending?.library.isActive) { _, active in + if active == false { pending = nil } + } + .onDisappear { pending = nil } + } + } + + struct ConversationLibraryRow: View { + let conversation: Conversation + @Bindable var library: LibraryModel + let onSelect: @MainActor (String) -> Void + var onMutation: (@MainActor () async -> Void)? = nil + var onSelectForSelection: (@MainActor () -> Void)? = nil + var leadingInset: CGFloat = 20 + var contextMenuEnabled = true + var selectedChatID: String? = nil + var activity: ConversationActivity = .idle + @Environment(\.requestChatDeletion) private var requestChatDeletion + @State private var renaming = false + @State private var moving = false + var body: some View { + HStack(spacing: 0) { + titleButton + Menu { + actions + } label: { + Image(systemName: "ellipsis").font(.body.weight(.semibold)).foregroundStyle(MapleAccent.action) + .frame(width: 44, height: 44).contentShape(Rectangle()) + } + .menuStyle(.button).buttonStyle(.plain) + .accessibilityLabel(libraryText("Chat actions")) + .accessibilityIdentifier("library.chat-actions." + conversation.id) + } + .listRowInsets(.vertical, 0) + .listRowInsets(.leading, leadingInset).listRowInsets(.trailing, 12) + .listRowSeparatorTint(MapleSurface.border.opacity(0.28)) + .listRowSeparator(.visible).listRowBackground(Color.clear) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + // A destructive swipe role optimistically removes its cell. + // Only the separate confirmation action may delete anything. + Button { + requestDeletion() + } label: { + Label { + libraryText("Delete") + } icon: { + Image(systemName: "trash") + } + } + .tint(.red) + .accessibilityIdentifier("library.swipe-delete." + conversation.id) + Button { + renaming = true + } label: { + Label { + libraryText("Rename") + } icon: { + Image(systemName: "pencil") + } + } + } + .disabled(library.isMutating) + .sheet(isPresented: $renaming) { + NavigationStack { + RenameChatForm(conversation: conversation, library: library, onMutation: onMutation) + } + } + .sheet(isPresented: $moving, onDismiss: { Task { await changed() } }) { + NavigationStack { MoveChatsView(library: library, ids: [conversation.id]) } + } + } + @ViewBuilder private var titleButton: some View { + if contextMenuEnabled { + conversationButton.contextMenu { actions } + } else { + // The row's long press starts a drag. Its visible ellipsis and + // swipe actions remain available without a competing menu. + conversationButton + } + } + private var conversationButton: some View { + Button { + onSelect(conversation.id) + } label: { + ConversationTitle( + conversation: conversation, emphasized: selectedChatID == conversation.id, activity: activity + ) + .frame(maxWidth: .infinity, minHeight: 50, alignment: .leading) + .contentShape(Rectangle()).foregroundStyle(.primary) + } + .buttonStyle(.plain) + .accessibilityIdentifier("library.chat." + conversation.id) + .accessibilityLabel(conversationActivityLabel(title: displayedTitle(conversation), activity: activity)) + .accessibilityAddTraits(selectedChatID == conversation.id ? [.isSelected] : []) + } + @ViewBuilder private var actions: some View { + if let onSelectForSelection { + Button(action: onSelectForSelection) { + Label { + libraryText("Select") + } icon: { + Image(systemName: "checkmark.circle") + } + } + } + Button { + Task { + await library.setPinned(id: conversation.id, pinned: !conversation.pinned); await changed() + } + } label: { + Label { + libraryText(conversation.pinned ? "Unpin" : "Pin") + } icon: { + Image(systemName: conversation.pinned ? "pin.slash" : "pin") + } + } + Button { + moving = true + } label: { + Label { + libraryText("Move to project") + } icon: { + Image(systemName: "folder") + } + } + Button { + renaming = true + } label: { + Label { + libraryText("Rename") + } icon: { + Image(systemName: "pencil") + } + } + Button(role: .destructive) { + requestDeletion() + } label: { + Label { + libraryText("Delete") + } icon: { + Image(systemName: "trash") + } + } + } + private func requestDeletion() { + requestChatDeletion( + LibraryChatDeletionRequest( + id: conversation.id, title: displayedTitle(conversation), library: library, onMutation: onMutation)) + } + private func changed() async { if library.issue == nil { await onMutation?() } } + } + + struct MoveChatsView: View { + @Bindable var library: LibraryModel + let ids: Set + var onMoved: (@MainActor () -> Void)? = nil + @Environment(\.dismiss) private var dismiss + var body: some View { + List { + Button { + move(nil) + } label: { + libraryText("No project") + } + ForEach(library.projects, id: \.id) { project in Button(project.name) { move(project.id) } } + if let issue = library.issue { LibraryIssueView(issue: issue) } + } + .listStyle(.plain) + .listRowBackground(Color.clear) + .scrollContentBackground(.hidden) + .background(MapleSurface.canvas) + .disabled(library.isMutating) + .navigationTitle(Text("Move to project", tableName: "Library", bundle: .module)) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button { + dismiss() + } label: { + libraryText("Cancel") + }.disabled(library.isMutating) + } + } + .interactiveDismissDisabled(library.isMutating) + .task { await library.loadProjects() } + } + private func move(_ project: String?) { + Task { + if await library.moveConversations(ids: ids, projectID: project) { + onMoved?(); dismiss() + } + } + } + } + + struct DeleteLibraryContentView: View { + let title: String + var actionTitle: String? = nil + let explanation: Text + let action: @MainActor () async -> LibraryIssue? + @Environment(\.dismiss) private var dismiss + @State private var confirmation = "" + @State private var working = false + @State private var issue: LibraryIssue? + var body: some View { + List { + explanation + TextField( + text: $confirmation, + prompt: libraryText("Type DELETE to confirm").foregroundStyle(MapleFieldPrompt()) + ) { libraryText("Type DELETE to confirm") } + .textInputAutocapitalization(.characters).autocorrectionDisabled() + .accessibilityIdentifier("library.delete-confirmation") + Button(role: .destructive) { + working = true + Task { + issue = await action(); working = false; if issue == nil { dismiss() } + } + } label: { + libraryText(actionTitle ?? title) + }.disabled(confirmation != "DELETE" || working) + .accessibilityIdentifier("library.delete-confirm") + if let issue { LibraryIssueView(issue: issue) } + } + .listStyle(.plain) + .listRowBackground(Color.clear) + .scrollContentBackground(.hidden) + .background(MapleSurface.canvas) + .navigationTitle(libraryText(title)) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button { + dismiss() + } label: { + libraryText("Cancel") + }.disabled(working) + } + } + .interactiveDismissDisabled(working) + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Library/InstructionEditorView.swift b/apps/maple-ios/Sources/MapleAppUI/Library/InstructionEditorView.swift new file mode 100644 index 000000000..4abe074f8 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Library/InstructionEditorView.swift @@ -0,0 +1,107 @@ +#if os(iOS) + import MapleAppCore + import SwiftUI + + public struct InstructionEditorView: View { + @Bindable private var editor: InstructionEditor + @Environment(\.dismiss) private var dismiss + @State private var confirmDiscard = false + public init(editor: InstructionEditor) { self.editor = editor } + public var body: some View { + VStack(alignment: .leading, spacing: 12) { + if editor.isLoaded { + Text( + "Instructions apply to future messages. Save to apply your changes.", tableName: "Library", + bundle: .module + ) + .font(.callout).foregroundStyle(.secondary) + TextEditor(text: $editor.text).disabled(editor.isSaving) + .overlay(alignment: .topLeading) { + if isProject, editor.text.isEmpty { + libraryText("e.g. Use concise bullet points. Focus on practical examples.") + .foregroundStyle(MapleFieldPrompt()).padding(.top, 8).padding(.leading, 5) + .allowsHitTesting(false).accessibilityHidden(true) + } + } + .accessibilityLabel(Text("Instructions", tableName: "Library", bundle: .module)) + .accessibilityIdentifier("library.instructions-text") + .scrollContentBackground(.hidden) + if isProject { + libraryText("Leave this empty to clear the project's custom instructions.") + .font(.footnote).foregroundStyle(.secondary) + } + } else if !editor.isLoading { + Button { + Task { await editor.load() } + } label: { + Text("Try again", tableName: "Library", bundle: .module) + } + } + if let issue = editor.issue { LibraryIssueView(issue: issue) } + } + .padding().frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(MapleSurface.canvas) + .navigationTitle(Text("Instructions", tableName: "Library", bundle: .module)) + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden() + .interactiveDismissDisabled(editor.isDirty || editor.isSaving) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button { + if editor.isDirty { confirmDiscard = true } else { dismiss() } + } label: { + Text("Cancel", tableName: "Library", bundle: .module) + }.disabled(editor.isSaving).accessibilityIdentifier("library.instructions-cancel") + } + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + await editor.save(); if editor.didSave { dismiss() } + } + } label: { + Text("Save", tableName: "Library", bundle: .module) + }.disabled(!editor.isLoaded || editor.isSaving || !editor.isDirty) + .accessibilityIdentifier("library.instructions-save") + } + } + .confirmationDialog( + Text("Discard unsaved changes?", tableName: "Library", bundle: .module), isPresented: $confirmDiscard, + titleVisibility: .visible + ) { + Button(role: .destructive) { + editor.discardChanges(); dismiss() + } label: { + Text("Discard changes", tableName: "Library", bundle: .module) + } + } + .task { await editor.load() } + } + private var isProject: Bool { + if case .project = editor.destination { return true } + return false + } + } + + struct LibraryIssueView: View { + let issue: LibraryIssue + var body: some View { + libraryText(message).foregroundStyle(MapleRedText()) + .accessibilityIdentifier("library.error") + } + private var message: String { + switch issue { + case .connection: "Maple could not load or update this information. Check your connection and try again." + case .invalidInput: "Check the name or selection and try again." + case .invalidResponse: "Maple could not verify the service response. Refresh before trying again." + case .busy: "Another account update is in progress. Wait for it to finish." + case .retired: "Your sign-in changed. Reopen this screen to continue." + case .quiescence: + "Maple could not confirm that all affected requests stopped. Nothing was deleted. Try again after the requests settle." + case .cleanupRequired: + "The server deletion finished, but local cleanup needs another attempt before more changes can be made." + case .partialMutation: + "Some selected chats could not be updated. The remaining selection still needs attention." + } + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Library/LibraryChatDragDrop.swift b/apps/maple-ios/Sources/MapleAppUI/Library/LibraryChatDragDrop.swift new file mode 100644 index 000000000..020efd5ae --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Library/LibraryChatDragDrop.swift @@ -0,0 +1,324 @@ +#if os(iOS) + import MapleAppCore + import Observation + import SwiftUI + import UIKit + import UniformTypeIdentifiers + + /// The drag contains no exported chat metadata. Its local object is usable + /// only by this menu instance and the exact account-session generation. + @MainActor final class LibraryChatDragLease { + let token = UUID() + let scope: AccountScope + let conversationID: String + weak var library: LibraryModel? + init(id: String, library: LibraryModel) { + conversationID = id; scope = library.scope; self.library = library + } + } + + @MainActor @Observable final class LibraryChatDragContext { + let scope: AccountScope + private(set) var active: LibraryChatDragLease? + var target: LibraryConversationDestination? + private var working = false + @ObservationIgnored private var prepared: LibraryChatDragLease? + init(scope: AccountScope) { self.scope = scope } + var isDragging: Bool { active != nil } + func begin(id: String, library: LibraryModel) -> LibraryChatDragLease? { + guard !working, active == nil, library.scope == scope, !library.isMutating, + library.visibleConversations.contains(where: { $0.id == id }) + else { return nil } + let lease = LibraryChatDragLease(id: id, library: library) + // Changing List sections while UIKit is lifting a row cancels the + // lift. Publish the empty Pinned target only after the drag begins. + prepared = lease; return lease + } + func activate(_ lease: LibraryChatDragLease) { + guard prepared === lease, !working, active == nil, lease.scope == scope, + let library = lease.library, library.scope == scope, !library.isMutating, + library.visibleConversations.contains(where: { $0.id == lease.conversationID }) + else { libraryDragTrace(.activationRejected); return } + prepared = nil; active = lease; target = nil + libraryDragTrace(.activated) + } + func cancel(_ lease: LibraryChatDragLease? = nil) { + if lease == nil || prepared === lease { prepared = nil } + if lease == nil || active === lease { active = nil; target = nil } + } + func admits( + _ lease: LibraryChatDragLease, into library: LibraryModel, destination: LibraryConversationDestination + ) -> Bool { + guard !working, active === lease, scope == library.scope, scope == lease.scope, + !library.isMutating, let source = lease.library, !source.isMutating, + source.visibleConversations.contains(where: { $0.id == lease.conversationID }) + else { return false } + if case .project(let id) = destination { return library.projects.contains { $0.id == id } } + return true + } + func commit( + _ lease: LibraryChatDragLease, into library: LibraryModel, destination: LibraryConversationDestination, + onMutation: (@MainActor () async -> Void)? + ) { + guard admits(lease, into: library, destination: destination) else { return } + libraryDragTrace(.commit) + cancel(lease); working = true + Task { [weak self] in + await library.relocateConversation(id: lease.conversationID, to: destination, from: lease.scope) + libraryDragTrace(library.issue == nil ? .acknowledged : .failed) + if library.issue == nil { await onMutation?() } + self?.working = false + } + } + } + + struct LibraryChatDragSource: ViewModifier { + let context: LibraryChatDragContext? + let library: LibraryModel + let id: String + let enabled: Bool + func body(content: Content) -> some View { + content.background { + if let context { + LibraryChatDragAnchor(context: context, library: library, id: id, enabled: enabled) + .frame(width: 0, height: 0) + } + } + } + } + + struct LibraryChatDropTarget: ViewModifier { + let context: LibraryChatDragContext? + let library: LibraryModel + let destination: LibraryConversationDestination? + let enabled: Bool + var onMutation: (@MainActor () async -> Void)? = nil + func body(content: Content) -> some View { + content + .background { + if let context, let destination, context.target == destination { + MapleAccent.prominent.opacity(0.12) + } + } + .background { + if let context, let destination { + LibraryChatDropAnchor( + context: context, library: library, destination: destination, + enabled: enabled, onMutation: onMutation + ).frame(width: 0, height: 0) + } + } + } + } + + /// SwiftUI owns the List. Attach narrowly to its public UIKit cell or + /// supplementary header, without replacing selection or scroll gestures. + @MainActor private final class LibraryInteractionAnchorView: UIView { + var reconcile: (() -> Void)? + override func didMoveToSuperview() { super.didMoveToSuperview(); reconcile?() } + override func didMoveToWindow() { super.didMoveToWindow(); reconcile?() } + override func layoutSubviews() { super.layoutSubviews(); reconcile?() } + var interactionHost: UIView? { + var candidate = superview + while let view = candidate { + if let cell = view as? UICollectionViewCell { return cell.contentView } + if let cell = view as? UITableViewCell { return cell.contentView } + if view is UICollectionReusableView || view is UITableViewHeaderFooterView { return view } + candidate = view.superview + } + return nil + } + } + + @MainActor private struct LibraryChatDragAnchor: UIViewRepresentable { + let context: LibraryChatDragContext + let library: LibraryModel + let id: String + let enabled: Bool + func makeCoordinator() -> Coordinator { Coordinator(self) } + func makeUIView(context: Context) -> LibraryInteractionAnchorView { + let view = LibraryInteractionAnchorView() + view.isUserInteractionEnabled = false + view.reconcile = { [weak coordinator = context.coordinator, weak view] in + if let view { coordinator?.reconcile(view) } + } + return view + } + func updateUIView(_ view: LibraryInteractionAnchorView, context: Context) { + context.coordinator.parent = self; context.coordinator.reconcile(view) + } + static func dismantleUIView(_ view: LibraryInteractionAnchorView, coordinator: Coordinator) { + view.reconcile = nil; coordinator.detach() + } + @MainActor final class Coordinator: NSObject, UIDragInteractionDelegate { + var parent: LibraryChatDragAnchor + private var interaction: UIDragInteraction? + private weak var host: UIView? + init(_ parent: LibraryChatDragAnchor) { self.parent = parent } + func reconcile(_ anchor: LibraryInteractionAnchorView) { + guard anchor.window != nil, let target = anchor.interactionHost else { detach(); return } + if host !== target { + detach(); let interaction = UIDragInteraction(delegate: self) + interaction.isEnabled = parent.enabled; target.addInteraction(interaction) + self.interaction = interaction; host = target + libraryDragTrace(.source) + } else { + interaction?.isEnabled = parent.enabled + } + } + func detach() { + if let interaction { host?.removeInteraction(interaction) } + interaction = nil; host = nil + } + func dragInteraction(_ interaction: UIDragInteraction, itemsForBeginning session: any UIDragSession) + -> [UIDragItem] + { + libraryDragTrace(.lift) + guard parent.enabled, + let lease = parent.context.begin(id: parent.id, library: parent.library) + else { libraryDragTrace(.liftRejected); return [] } + libraryDragTrace(.liftAccepted) + let token = Data(lease.token.uuidString.utf8) + let provider = NSItemProvider() + provider.registerDataRepresentation( + forTypeIdentifier: UTType.data.identifier, visibility: .ownProcess + ) { completion in + completion(token, nil); return nil + } + let item = UIDragItem(itemProvider: provider); item.localObject = lease + return [item] + } + func dragInteraction(_ interaction: UIDragInteraction, sessionWillBegin session: any UIDragSession) { + libraryDragTrace(.began) + if let lease = session.items.first?.localObject as? LibraryChatDragLease { + parent.context.activate(lease) + } + } + func dragInteraction( + _ interaction: UIDragInteraction, sessionAllowsMoveOperation session: any UIDragSession + ) + -> Bool + { true } + func dragInteraction( + _ interaction: UIDragInteraction, sessionIsRestrictedToDraggingApplication session: any UIDragSession + ) -> Bool { true } + func dragInteraction( + _ interaction: UIDragInteraction, session: any UIDragSession, didEndWith operation: UIDropOperation + ) { + libraryDragTrace(operation == .move ? .moved : .cancelled) + for item in session.items { + if let lease = item.localObject as? LibraryChatDragLease { parent.context.cancel(lease) } + } + } + } + } + + @MainActor private struct LibraryChatDropAnchor: UIViewRepresentable { + let context: LibraryChatDragContext + let library: LibraryModel + let destination: LibraryConversationDestination + let enabled: Bool + let onMutation: (@MainActor () async -> Void)? + func makeCoordinator() -> Coordinator { Coordinator(self) } + func makeUIView(context: Context) -> LibraryInteractionAnchorView { + let view = LibraryInteractionAnchorView() + view.isUserInteractionEnabled = false + view.reconcile = { [weak coordinator = context.coordinator, weak view] in + if let view { coordinator?.reconcile(view) } + } + return view + } + func updateUIView(_ view: LibraryInteractionAnchorView, context: Context) { + context.coordinator.parent = self; context.coordinator.reconcile(view) + } + static func dismantleUIView(_ view: LibraryInteractionAnchorView, coordinator: Coordinator) { + view.reconcile = nil; coordinator.detach() + } + @MainActor final class Coordinator: NSObject, UIDropInteractionDelegate { + var parent: LibraryChatDropAnchor + private var interaction: UIDropInteraction? + private weak var host: UIView? + init(_ parent: LibraryChatDropAnchor) { self.parent = parent } + func reconcile(_ anchor: LibraryInteractionAnchorView) { + guard anchor.window != nil, let target = anchor.interactionHost else { detach(); return } + if host !== target { + detach(); let interaction = UIDropInteraction(delegate: self) + target.addInteraction(interaction); self.interaction = interaction; host = target + libraryDragTrace(.target) + } + } + func detach() { + if let interaction { host?.removeInteraction(interaction) } + interaction = nil; host = nil + } + private func lease(_ session: any UIDropSession) -> LibraryChatDragLease? { + guard parent.enabled, session.localDragSession != nil, session.items.count == 1, + let lease = session.items.first?.localObject as? LibraryChatDragLease, + parent.context.admits(lease, into: parent.library, destination: parent.destination) + else { return nil } + return lease + } + func dropInteraction(_ interaction: UIDropInteraction, canHandle session: any UIDropSession) -> Bool { + let admitted = lease(session) != nil + libraryDragTrace(admitted ? .admitted : .rejected) + return admitted + } + func dropInteraction(_ interaction: UIDropInteraction, sessionDidUpdate session: any UIDropSession) + -> UIDropProposal + { + guard lease(session) != nil else { return UIDropProposal(operation: .forbidden) } + libraryDragTrace(.hover) + parent.context.target = parent.destination + return UIDropProposal(operation: .move) + } + func dropInteraction(_ interaction: UIDropInteraction, sessionDidExit session: any UIDropSession) { + if parent.context.target == parent.destination { parent.context.target = nil } + } + func dropInteraction(_ interaction: UIDropInteraction, sessionDidEnd session: any UIDropSession) { + if parent.context.target == parent.destination { parent.context.target = nil } + } + func dropInteraction(_ interaction: UIDropInteraction, performDrop session: any UIDropSession) { + libraryDragTrace(.performed) + guard let lease = lease(session) else { return } + parent.context.commit( + lease, into: parent.library, destination: parent.destination, onMutation: parent.onMutation) + } + } + } + + private enum LibraryChatDragEvent: String, CaseIterable { + case source, target, lift, liftAccepted, liftRejected, began, activated, activationRejected, admitted, + rejected, hover, performed, commit, acknowledged, failed, moved, cancelled + } + + @MainActor private func libraryDragTrace(_ event: LibraryChatDragEvent) { + #if DEBUG + LibraryChatDragTrace.shared.record(event.rawValue) + #endif + } + + #if DEBUG + /// Numeric events only, never identifiers, titles or account metadata. + /// Both explicit synthetic-fixture switches are required; normal app + /// launches cannot enable these diagnostics. + @MainActor @Observable final class LibraryChatDragTrace { + static let shared = LibraryChatDragTrace() + private let enabled: Bool + private var counts: [String: Int] = [:] + private init() { + let arguments = ProcessInfo.processInfo.arguments + enabled = + arguments.contains("--maple-library-controls-fixture") + && arguments.contains("--maple-library-drag-proof") + } + fileprivate func record(_ event: String) { + guard enabled else { return } + counts[event, default: 0] += 1 + } + var summary: String { + LibraryChatDragEvent.allCases.map { $0.rawValue + ":" + String(counts[$0.rawValue, default: 0]) } + .joined(separator: " ") + } + } + #endif +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Library/LibraryCleanupIdentityBadge.swift b/apps/maple-ios/Sources/MapleAppUI/Library/LibraryCleanupIdentityBadge.swift new file mode 100644 index 000000000..51ac1aa33 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Library/LibraryCleanupIdentityBadge.swift @@ -0,0 +1,22 @@ +#if os(iOS) && DEBUG + import CryptoKit + import Foundation + import MapleAppCore + import SwiftUI + + /// Local test runner ownership marker. No credentials or raw account ID are + /// exposed, and the root admits this only with its explicit DEBUG launch flag. + struct LibraryCleanupIdentityBadge: View { + let scope: AccountScope + var body: some View { + let encoded = try? JSONEncoder().encode([scope.apiOrigin, scope.principalID]) + let fingerprint = + encoded.map { data in + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } ?? "unavailable" + Text(verbatim: "Account diagnostics").font(.system(size: 8)).lineLimit(1) + .accessibilityLabel(fingerprint) + .accessibilityIdentifier("library.cleanup-account") + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Library/LibraryControlsFixtureView.swift b/apps/maple-ios/Sources/MapleAppUI/Library/LibraryControlsFixtureView.swift new file mode 100644 index 000000000..878234e64 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Library/LibraryControlsFixtureView.swift @@ -0,0 +1,615 @@ +#if os(iOS) && DEBUG + import Foundation + import MapleAppCore + import MapleSDK + import Observation + import SwiftUI + + /// Only remote services and storage are synthetic. Navigation destinations, + /// selection, typed confirmation and deletion ownership use shipping code. + /// This fixture constructs no SDK client and can never reach a real account. + struct LibraryControlsFixtureView: View { + @State private var model = LibraryControlsFixtureModel() + @State private var compactMenuLease = CompactMenuLease() + @Environment(\.horizontalSizeClass) private var sizeClass + @Environment(\.accessibilityReduceMotion) private var reduceMotion + var body: some View { + VStack(spacing: 0) { + Group { + if model.optionsProof { + VStack(spacing: 16) { + Button { + model.showOptions = true + } label: { + Image(systemName: "slider.horizontal.3").frame(minWidth: 44, minHeight: 44) + } + .accessibilityLabel("Chat options") + .accessibilityIdentifier("chat-options.fixture.open") + Button("Manage projects") { model.showProjects = true } + .accessibilityIdentifier("chat-options.fixture.manage") + Text(model.selectedProjectID ?? "none") + .accessibilityIdentifier("chat-options.fixture.project") + Text(model.account.selectedModelID) + .accessibilityIdentifier("chat-options.fixture.model") + Text(model.preferences.value.webSearchPreferred ? "web on" : "web off") + .accessibilityIdentifier("chat-options.fixture.web") + } + .disabled(!model.ready) + .sheet(isPresented: $model.showOptions) { + ChatOptionsSheet( + entitlements: model.account, preferences: model.preferences, library: model.library, + regularWidth: sizeClass == .regular, + selectedProjectID: model.selectedProjectID, + makeInstructionEditor: model.makeInstructions, + isCurrent: { model.ready && model.showOptions }, + onSelectProject: { id in + guard model.ready, model.showOptions else { return false } + model.selectedProjectID = id; return true + }, + onSelectModel: { id in + model.preferences.setSelectedModelID(id); model.showOptions = false + }) + } + .sheet(isPresented: $model.showProjects) { + ProjectManagementSheet( + library: model.library, makeInstructionEditor: model.makeInstructions, + isCurrent: { model.ready && model.showProjects }) + } + } else if model.dragProof || model.menuProof { + if model.accountProof { accountNavigation } else { menuLibrary } + } else { + NavigationStack { + List { + NavigationLink("History fixture") { + HistoryView(library: model.library, onSelectChat: { model.selected = $0 }) + }.accessibilityIdentifier("library.fixture.history") + NavigationLink("Project fixture") { + ProjectView( + project: model.project, library: model.library, + makeInstructionEditor: model.makeInstructions, + onSelectChat: { model.selected = $0 }, onNewChat: { _ in }) + }.accessibilityIdentifier("library.fixture.project") + } + .navigationTitle("Synthetic library controls") + .disabled(!model.ready) + } + } + } + .frame(maxHeight: .infinity) + Group { + if model.dragProof { + dragDiagnostics + } else if model.menuProof { + menuDeletionDiagnostics + } else { + diagnostics + } + } + .fixedSize(horizontal: false, vertical: true) + } + .task { await model.prepare() } + } + + private var menuLibrary: some View { + LibraryView( + library: model.library, makeLibrary: model.makeLibrary, + makeInstructionEditor: model.makeInstructions, + onSelectChat: model.selectChat, onNewChat: { _ in }, + settings: { + if model.accountProof { + AnyView( + SettingsView( + preferences: model.preferences, entitlements: model.account, + account: SettingsAccount( + email: "synthetic-library@example.test", isVerified: true, + isGuest: false, canChangePassword: false), + version: "Synthetic fixture", onAction: { _ in })) + } else { + AnyView( + Text("Synthetic settings").accessibilityIdentifier("library.fixture.settings") + .navigationTitle("Settings")) + } + }, + account: model.accountProof ? model.account : nil, + conversationActivity: { model.parityProof ? model.activities[$0] ?? .idle : .idle }, + projectActivity: { id in + model.parityProof && id == "fixture-project" ? model.activities["active"] ?? .idle : .idle + }, drawerNavigation: model.accountProof, compactRootBar: model.accountProof, + navigationFocusAvailable: model.accountProof ? compactMenuLease.menuVisible : nil) + } + + /// The compact root bar publishes to this existing shipping controller. + /// A standalone SwiftUI stack has no publisher to host its native menu. + private var accountNavigation: some View { + let scope = model.scope + return CompactMenuNavigation( + scope: scope, storageOwner: model.runtime.snapshot.storageOwner, + requestedMenu: true, reduceMotion: reduceMotion, lease: compactMenuLease, + library: menuLibrary.frame(maxWidth: .infinity, maxHeight: .infinity) + .background(MapleSurface.sidebar) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("library.drawer"), + chat: Color.clear.accessibilityHidden(true), + isCurrent: { [weak model] in model?.scope == scope && model?.library.isActive == true }, + presentationBlocked: { false }, + requestMenu: { _ in }, blockPresentation: { _, _ in }, chatShown: { _ in } + ) + .id(scope) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea(.container) + } + + private var menuDeletionDiagnostics: some View { + VStack(alignment: .leading, spacing: 0) { + Text(model.ready ? "ready" : "preparing").accessibilityIdentifier("library.fixture.ready") + Text("deletions \(model.service.events.filter { $0.hasPrefix("delete") }.count)") + .accessibilityIdentifier("library.fixture.deletion-count") + Text("Remaining " + model.service.conversations.map(\.id).joined(separator: ",")) + .accessibilityIdentifier("library.fixture.remaining") + Text(model.service.lastDelete.sorted().joined(separator: ",")) + .accessibilityIdentifier("library.fixture.deleted") + Text(model.service.events.joined(separator: "|")) + .accessibilityIdentifier("library.fixture.events") + } + .font(.caption2).lineLimit(1).frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 6).padding(.vertical, 3) + .frame(height: 76).background(.regularMaterial) + } + + private var diagnostics: some View { + VStack(alignment: .leading, spacing: 2) { + Text(model.ready ? "ready" : "preparing").accessibilityIdentifier("library.fixture.ready") + Text( + "Remaining " + + (model.service.conversations.isEmpty + ? "none" : model.service.conversations.map(\.id).joined(separator: ",")) + ) + .accessibilityIdentifier("library.fixture.remaining") + Text(model.service.projectExists ? "project retained" : "project deleted") + .accessibilityIdentifier("library.fixture.project-state") + Text(model.service.events.joined(separator: "|")) + .accessibilityIdentifier("library.fixture.events") + Text("deletions \(model.service.events.filter { $0.hasPrefix("delete") }.count)") + .accessibilityIdentifier("library.fixture.deletion-count") + Text( + model.service.lastMove.sorted().joined(separator: ",") + " to " + + (model.service.lastMoveProject ?? "none") + ) + .accessibilityIdentifier("library.fixture.moved") + Text(model.service.lastDelete.sorted().joined(separator: ",")) + .accessibilityIdentifier("library.fixture.deleted") + Text("pages \(model.service.pageRequests)") + .accessibilityIdentifier("library.fixture.pages") + Text(model.runtime.queuedTurns.map(\.draft.text).joined(separator: "|")) + .accessibilityIdentifier("library.fixture.queue-items") + Text(model.runtime.isQueuePaused ? "paused" : "running") + .accessibilityIdentifier("library.fixture.queue") + Text("Draft " + (model.runtime.draftText.isEmpty ? "empty" : model.runtime.draftText)) + .accessibilityIdentifier("library.fixture.draft") + Text(model.removed ? "removed" : "retained").accessibilityIdentifier("library.fixture.owner") + }.font(.caption2).frame(maxWidth: .infinity, alignment: .leading).padding(6) + .background(.regularMaterial) + } + private var dragDiagnostics: some View { + let alpha = model.service.conversations.first { $0.id == "alpha" } + let state = + alpha.map { + "alpha:" + ($0.projectId ?? "recent") + ":" + ($0.pinned ? "pinned" : "unpinned") + } ?? "alpha:missing" + let trace = LibraryChatDragTrace.shared.summary + return VStack(alignment: .leading, spacing: 0) { + Text(model.ready ? "ready" : "preparing") + .accessibilityIdentifier("library.fixture.ready") + .frame(height: 14) + Text(state).accessibilityIdentifier("library.fixture.alpha-state") + .accessibilityLabel(Text(verbatim: state)).frame(height: 14) + Text(trace).accessibilityIdentifier("library.fixture.drag-trace") + .accessibilityLabel(Text(verbatim: trace)).frame(height: 14) + } + // The fixture must not resize the shipping List as event counts + // change, or shift a native drag's pre-resolved release target. + .font(.caption2).lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 6).padding(.vertical, 3) + .frame(height: 48).background(.regularMaterial) + } + } + + @MainActor @Observable private final class LibraryControlsFixtureModel { + let scope: AccountScope + let service: LibraryControlsFixtureService + let runtime: ChatRuntime + let library: LibraryModel + let dragProof: Bool + let menuProof: Bool + let optionsProof: Bool + let parityProof: Bool + let accountProof: Bool + let account: AccountEntitlements + let preferences: PreferencesController + var selectedProjectID: String? + var showOptions = false + var showProjects = false + private let deletions: ChatDeletionCoordinator + @ObservationIgnored private var menuLibraries: [LibraryModel] = [] + var selected = "" + var ready = false + var activities: [String: ConversationActivity] = ["alpha": .running, "beta": .unread, "active": .unread] + var removed = false + private var preparing = false + let project = ConversationProjectListItem( + id: "fixture-project", object: "project", name: "Synthetic project", createdAt: 0, updatedAt: 0) + + init() { + let arguments = ProcessInfo.processInfo.arguments + let dragProof = arguments.contains("--maple-library-drag-proof") + let optionsProof = arguments.contains("--maple-chat-options-proof") + let parityProof = arguments.contains("--maple-library-parity-proof") + let accountProof = arguments.contains("--maple-library-account-proof") + self.dragProof = dragProof + menuProof = arguments.contains("--maple-library-menu-proof") || parityProof || accountProof + self.optionsProof = optionsProof + self.parityProof = parityProof + self.accountProof = accountProof + let scope = AccountScope(apiOrigin: "http://127.0.0.1:1", principalID: "synthetic-library-controls") + self.scope = scope + let service = LibraryControlsFixtureService( + failPage: arguments.contains("--maple-library-controls-page-failure"), + failDelete: arguments.contains("--maple-library-controls-delete-failure"), dragProof: dragProof, + optionsProof: optionsProof || parityProof, + searchProof: arguments.contains("--maple-library-search-proof")) + let store = LibraryControlsFixtureStore() + let runtime = ChatRuntime(scope: scope, service: service, persistence: store, conversationID: "active") + let deletions = ChatDeletionCoordinator(scope: scope, retained: [], service: service, persistence: store) + self.service = service; self.runtime = runtime; self.deletions = deletions + library = LibraryModel(scope: scope, service: service, deletions: deletions) + let accountService = ChatOptionsFixtureAccountService(accountProof: accountProof) + account = AccountEntitlements( + scope: scope, isGuest: false, billing: accountService, catalog: accountService) + preferences = PreferencesController(scope: scope, store: ChatOptionsFixturePreferences()) + deletions.register(runtime, projectID: "fixture-project") + deletions.onRemoved = { [weak self] owner in + if owner === self?.runtime { self?.removed = true } + } + attach(library) + } + func prepare() async { + guard !ready, !preparing else { return }; preparing = true + defer { preparing = false } + if optionsProof { + await preferences.load(); await account.refresh(); await library.loadProjects() + } else if accountProof { + await account.refresh() + } + if ProcessInfo.processInfo.arguments.contains("--maple-library-controls-active") { + runtime.draftText = "Synthetic active request" + guard runtime.send() else { return } + // A real runtime must consume created before the UI admits deletion. + for _ in 0..<200 { + if runtime.snapshot.pendingRun?.responseID == "fixture-response" { break } + try? await Task.sleep(nanoseconds: 10_000_000) + } + guard runtime.snapshot.pendingRun?.responseID == "fixture-response" else { return } + runtime.draftText = "Synthetic queued request" + guard runtime.enqueueDraft() else { return } + runtime.draftText = "Unsent draft survives a failed deletion" + await runtime.saveDraft() + } + ready = true + } + func makeInstructions(_ destination: InstructionDestination) -> InstructionEditor { + InstructionEditor(scope: scope, destination: destination, service: service) + } + func selectChat(_ id: String) { + selected = id + if activities[id] == .unread { activities[id] = .idle } + } + func makeLibrary() -> LibraryModel { + let value = LibraryModel(scope: scope, service: service, deletions: deletions) + attach(value); return value + } + private func attach(_ value: LibraryModel) { + menuLibraries.append(value) + value.onConversationUpdated = { [weak self, weak value] updated in + guard let self else { return } + for other in menuLibraries where other !== value { + other.receiveConversationUpdate(updated, from: scope) + } + } + value.onProjectDeleted = { [weak self] id in + if self?.selectedProjectID == id { self?.selectedProjectID = nil } + } + } + } + + private actor LibraryControlsFixtureStore: ChatPersistence { + var values: [String: ChatSnapshot] = [:] + func load(chatID: String, scope: AccountScope) -> ChatSnapshot? { values[chatID] } + func loadAll(scope: AccountScope) -> [ChatSnapshot] { Array(values.values) } + func save(_ snapshot: ChatSnapshot, scope: AccountScope) { + if (values[snapshot.storageKey]?.revision ?? 0) <= snapshot.revision { + values[snapshot.storageKey] = snapshot + } + } + func remove(chatID: String, scope: AccountScope) { values.removeValue(forKey: chatID) } + func rekey(from: String, to: ChatSnapshot, scope: AccountScope) { + values.removeValue(forKey: from); values[to.storageKey] = to + } + } + + @MainActor @Observable final class LibraryControlsFixtureService: LibraryServing, ChatServing { + var conversations: [Conversation] = [ + LibraryControlsFixtureService.conversation("alpha", title: "Alpha chat"), + LibraryControlsFixtureService.conversation("beta", title: "Beta chat"), + LibraryControlsFixtureService.conversation( + "active", title: "Active project chat", project: "fixture-project"), + LibraryControlsFixtureService.conversation("outside", title: "Outside chat"), + ] + var projectExists = true + var events: [String] = [] + var lastMove: [String] = [] + var lastMoveProject: String? + var lastDelete: [String] = [] + var pageRequests = 0 + private var failPage: Bool + private var failDelete: Bool + private var responseStatus: ResponseStatus = .inProgress + private var continuation: AsyncThrowingStream.Continuation? + private var messageID: String? + private let dragProof: Bool + private let optionsProof: Bool + private var optionProjects: [String: ConversationProject] = [ + "fixture-project": ConversationProject( + id: "fixture-project", object: "project", name: "Synthetic project", createdAt: 0, updatedAt: 0) + ] + private var projectSequence = 0 + + init(failPage: Bool, failDelete: Bool, dragProof: Bool, optionsProof: Bool, searchProof: Bool = false) { + self.failPage = failPage; self.failDelete = failDelete; self.dragProof = dragProof + self.optionsProof = optionsProof + if searchProof { + conversations = [ + Self.conversation("project-alpha", title: "Alpha project chat", project: "fixture-project"), + Self.conversation("project-beta", title: "Beta project chat", project: "fixture-project"), + ] + } + } + private static func conversation(_ id: String, title: String, project: String? = nil) -> Conversation { + Conversation( + id: id, object: "conversation", createdAt: 0, metadata: ["title": .string(title)], + projectId: project, pinned: false, lastActivityAt: 0) + } + func listConversations(_ parameters: ConversationsListParameters) async throws -> APIPage { + pageRequests += 1 + if parameters.page.after != nil, failPage { + failPage = false; throw URLError(.notConnectedToInternet) + } + let rows = conversations.filter { + (parameters.projectId == nil || $0.projectId == parameters.projectId) + && (parameters.unassignedProject != true || $0.projectId == nil) + && (parameters.pinned == nil || $0.pinned == parameters.pinned) + } + let start = + parameters.page.after.flatMap { after in rows.firstIndex { $0.id == after }.map { $0 + 1 } } ?? 0 + let end = min(rows.count, start + 2) + let values = Array(rows[start.. Conversation { + guard let result = conversations.first(where: { $0.id == id }) else { + throw ResearchAPIError.invalidArgument + } + return result + } + func updateConversation( + id: String, metadata: [String: JSONValue]?, projectId: NullableUpdate, pinned: Bool? + ) async throws -> Conversation { + guard let index = conversations.firstIndex(where: { $0.id == id }) else { + throw ResearchAPIError.invalidArgument + } + if let metadata { conversations[index].metadata = metadata } + if let pinned { conversations[index].pinned = pinned } + switch projectId { + case .clear: conversations[index].projectId = nil + case .set(let id): conversations[index].projectId = id + case .unchanged: break + } + return conversations[index] + } + func deleteConversation(id: String) async throws -> DeletedObject { + throw ResearchAPIError.invalidArgument + } + func deleteAllConversations() async throws -> DeletedObject { + events.append("delete-all"); conversations = [] + return DeletedObject(object: "conversation", deleted: true) + } + func batchDeleteConversations(ids: [String]) async throws -> BatchDeleteResponse { + guard !ids.isEmpty, ids.count <= 20 else { throw ResearchAPIError.invalidArgument } + lastDelete = ids; events.append("delete-selected") + conversations.removeAll { ids.contains($0.id) } + return BatchDeleteResponse( + object: "list", + data: ids.map { + BatchDeleteItem(id: $0, object: "conversation", deleted: true) + }) + } + func batchUpdateConversationProject(ids: [String], projectId: String?) async throws + -> BatchUpdateProjectResponse + { + guard !ids.isEmpty, ids.count <= 20 else { throw ResearchAPIError.invalidArgument } + lastMove = ids; lastMoveProject = projectId; events.append("move-selected") + for index in conversations.indices where ids.contains(conversations[index].id) { + conversations[index].projectId = projectId + } + return BatchUpdateProjectResponse(success: true) + } + func listProjects(_ parameters: ListParameters) async throws -> APIPage { + if optionsProof { + return APIPage( + data: optionProjects.values.sorted { $0.id < $1.id }.map { + ConversationProjectListItem( + id: $0.id, object: "project", name: $0.name, createdAt: 0, updatedAt: 0) + }) + } + var values: [ConversationProjectListItem] = [] + if projectExists { + values.append( + ConversationProjectListItem( + id: "fixture-project", object: "project", name: "Synthetic project", createdAt: 0, + updatedAt: 0)) + } + if dragProof { + values.append( + ConversationProjectListItem( + id: "fixture-project-2", object: "project", name: "Second project", createdAt: 0, updatedAt: 0)) + } + return APIPage(data: values) + } + func getProject(id: String) async throws -> ConversationProject { + if optionsProof, let project = optionProjects[id] { return project } + guard projectExists, id == "fixture-project" else { throw ResearchAPIError.invalidArgument } + return ConversationProject(id: id, object: "project", name: "Synthetic project", createdAt: 0, updatedAt: 0) + } + func createProject(name: String) async throws -> ConversationProject { + guard optionsProof else { throw ResearchAPIError.invalidArgument } + projectSequence += 1 + let value = ConversationProject( + id: "created-project-\(projectSequence)", object: "project", name: name, createdAt: 0, updatedAt: 0) + optionProjects[value.id] = value; events.append("create-project"); return value + } + func updateProject(id: String, name: String?, instructions: NullableUpdate) async throws + -> ConversationProject + { + guard optionsProof, let old = optionProjects[id] else { throw ResearchAPIError.invalidArgument } + let text: String? + switch instructions { + case .unchanged: text = old.instructions + case .set(let value): text = value + case .clear: text = nil + } + let value = ConversationProject( + id: id, object: "project", name: name ?? old.name, instructions: text, createdAt: 0, updatedAt: 0) + optionProjects[id] = value; events.append(name == nil ? "save-instructions" : "rename-project") + return value + } + func deleteProject(id: String) async throws -> DeletedObject { + if optionsProof { + guard optionProjects.removeValue(forKey: id) != nil else { throw ResearchAPIError.invalidArgument } + conversations.removeAll { $0.projectId == id }; events.append("delete-project") + return DeletedObject(id: id, object: "project", deleted: true) + } + guard id == "fixture-project", responseStatus == .cancelled else { throw ResearchAPIError.invalidArgument } + if failDelete { failDelete = false; events.append("delete-failed"); throw URLError(.cannotConnectToHost) } + events.append("delete-project"); projectExists = false + conversations.removeAll { $0.projectId == id } + return DeletedObject(id: id, object: "project", deleted: true) + } + func listInstructions(_ parameters: ListParameters) async throws -> APIPage { APIPage(data: []) } + func createInstruction(name: String, prompt: String, isDefault: Bool?) async throws -> Instruction { + throw ResearchAPIError.invalidArgument + } + func updateInstruction(id: String, name: String?, prompt: String?, isDefault: Bool?) async throws -> Instruction + { throw ResearchAPIError.invalidArgument } + func deleteInstruction(id: String) async throws -> DeletedObject { throw ResearchAPIError.invalidArgument } + func createConversation(metadata: [String: JSONValue]?, projectId: String?, pinned: Bool?) async throws + -> Conversation + { throw ResearchAPIError.invalidArgument } + func createResponse(_ request: ResponsesCreateRequest, maximumInferenceSends: Int) async throws + -> AsyncThrowingStream + { + guard request.conversation == "active", + case .some(.string(let id)) = request.metadata?["internal_message_id"], messageID == nil + else { throw ResearchAPIError.invalidArgument } + messageID = id; events.append("post") + let stream = AsyncThrowingStream.makeStream() + continuation = stream.continuation + struct Wrapper: Encodable { let response: ResearchResponse } + let encoder = JSONEncoder(); encoder.keyEncodingStrategy = .convertToSnakeCase + let decoder = JSONDecoder(); decoder.keyDecodingStrategy = .convertFromSnakeCase + let event = try decoder.decode( + ResponseLifecycleEvent.self, from: encoder.encode(Wrapper(response: response()))) + stream.continuation.yield(.created(event)); return stream.stream + } + func retrieveResponse(id: String) async throws -> ResearchResponse { + guard id == "fixture-response" else { throw ResearchAPIError.invalidArgument } + return response() + } + func cancelResponse(id: String) async throws -> ResearchResponse { + guard id == "fixture-response" else { throw ResearchAPIError.invalidArgument } + events.append("cancel-request") + try await Task.sleep(nanoseconds: 300_000_000) + responseStatus = .cancelled; events.append("cancel-confirm") + return response() + } + func getConversationItem(conversationId: String, itemId: String) async throws -> ConversationItem { + guard conversationId == "active", itemId == messageID else { + throw ResearchAPIError.http(status: 404, reason: .notFound) + } + return ConversationItem(id: itemId, responseId: "fixture-response", type: "message", role: "user") + } + func listConversationItems(conversationId: String, parameters: ListParameters) async throws -> APIPage< + ConversationItem + > { APIPage(data: []) } + private func response() -> ResearchResponse { + ResearchResponse( + id: "fixture-response", object: "response", createdAt: 0, status: responseStatus, model: "auto:quick") + } + } + private actor ChatOptionsFixtureAccountService: BillingServing, ModelCatalogServing { + private let accountProof: Bool + init(accountProof: Bool = false) { self.accountProof = accountProof } + func subscriptionStatus() throws -> BillingStatus { + var value: [String: Any] = [ + "is_subscribed": true, "product_id": "synthetic", "product_name": "Pro", + "subscription_status": "active", "can_chat": true, "chats_remaining": 50, + ] + if accountProof { + value["total_tokens"] = 100; value["used_tokens"] = 25 + value["api_credit_balance"] = 2.5; value["usage_reset_date"] = NSNull() + } + return try decode(value) + } + func teamStatus() throws -> TeamStatus { + if accountProof { + return try decode([ + "has_team_subscription": true, "team_created": true, "role": "admin", "is_team_admin": true, + "team_member_count": 1, "billed_seat_count": 0, "seat_limit_exceeded": true, + ]) + } + return try decode(["has_team_subscription": false, "team_created": false]) + } + func fetchCatalog() -> ModelCatalog { + let capabilities = ModelCapabilities(chat: true, vision: true, reasoning: true, toolUse: true) + func item(_ id: String, _ label: String) -> ModelCatalogItem { + ModelCatalogItem( + id: id, object: "model", created: 0, ownedBy: "synthetic", displayName: label, + shortName: label, contextWindow: 32768, maxContextTokens: 32768, access: "pro", + capabilities: capabilities, enabled: true, deprecated: false) + } + return ModelCatalog( + object: "list", + data: [item("options-quick", "Synthetic Quick"), item("options-power", "Synthetic Powerful")], + aliases: [ + ModelAlias( + id: .quick, label: "Quick", shortName: "Quick", description: "Fast, everyday responses", + targetModel: "options-quick", access: "pro", capabilities: capabilities), + ModelAlias( + id: .powerful, label: "Powerful", shortName: "Powerful", + description: "Deeper thinking & analysis", + targetModel: "options-power", access: "pro", capabilities: capabilities), + ], defaults: ModelDefaults(quick: .quick, powerful: .powerful)) + } + func retire() {} + private func decode(_ value: [String: Any]) throws -> T { + let decoder = JSONDecoder(); decoder.keyDecodingStrategy = .convertFromSnakeCase + return try decoder.decode(T.self, from: JSONSerialization.data(withJSONObject: value)) + } + } + + private actor ChatOptionsFixturePreferences: PreferencesStoring { + private var value = LocalPreferences() + func activate(_ scope: AccountScope) -> LocalPreferences { value } + func save(_ value: LocalPreferences, scope: AccountScope, revision: UInt64) { self.value = value } + func retire(_ scope: AccountScope, removePersisted: Bool) {} + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Library/LibraryUsagePresentation.swift b/apps/maple-ios/Sources/MapleAppUI/Library/LibraryUsagePresentation.swift new file mode 100644 index 000000000..0f85e1459 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Library/LibraryUsagePresentation.swift @@ -0,0 +1,84 @@ +import Foundation +import MapleAppCore + +/// Keeps the read-only status copy separate from the account's access policy. +enum LibraryUsagePresentation { + static func plan(_ label: BillingUsageSummary.PlanLabel, locale: Locale = .current) -> String { + switch label { + case .loading: + String(localized: "Loading...", table: "Library", bundle: .module, locale: locale) + case let .product(name): name + case let .productPlan(name): + String( + localized: "library.usage.product-plan", defaultValue: "\(name) Plan", + table: "Library", bundle: .module, locale: locale) + } + } + + static func percent(_ value: Int, locale: Locale = .current) -> String { + let number = value.formatted(.number.grouping(.never).locale(locale)) + return String( + localized: "library.usage.percent-used", defaultValue: "\(number)% used", + table: "Library", bundle: .module, locale: locale) + } + + static func credits(_ value: Double, locale: Locale = .current) -> String { + let number = value.formatted(.number.locale(locale)) + return String( + localized: "library.usage.credits", defaultValue: "+\(number) credits", + table: "Library", bundle: .module, locale: locale) + } + + static func reset( + _ value: BillingUsageReset, locale: Locale = .current, calendar: Calendar = .current + ) -> String { + switch value { + case .monthly: + return String(localized: "Resets Monthly", table: "Library", bundle: .module, locale: locale) + case .lessThanOneHour: + return String(localized: "Resets in less than 1 hour", table: "Library", bundle: .module, locale: locale) + case let .hours(count): + return String( + localized: "library.usage.reset-hours", + defaultValue: count == 1 ? "Resets in \(count) hour" : "Resets in \(count) hours", + table: "Library", bundle: .module, locale: locale) + case .tomorrow: + return String(localized: "Resets Tomorrow", table: "Library", bundle: .module, locale: locale) + case let .days(count): + return String( + localized: "library.usage.reset-days", + defaultValue: count == 1 ? "Resets in \(count) day" : "Resets in \(count) days", + table: "Library", bundle: .module, locale: locale) + case let .date(date): + let day = date.formatted( + Date.FormatStyle( + date: .omitted, time: .omitted, locale: locale, calendar: calendar, + timeZone: calendar.timeZone + ).month(.abbreviated).day()) + return String( + localized: "library.usage.reset-date", defaultValue: "Resets \(day)", + table: "Library", bundle: .module, locale: locale) + } + } +} + +public enum LibraryAccountAttention: Equatable, Sendable { + case usagePaused, setupRequired + + public init?(team: TeamStatus?) { + if team?.seatMismatch != nil { + self = .usagePaused + } else if let team, team.hasTeamSubscription && !team.teamCreated { + self = .setupRequired + } else { + return nil + } + } + + public var accessibilityLabel: String { + switch self { + case .usagePaused: String(localized: "Team usage paused", table: "Library", bundle: .module) + case .setupRequired: String(localized: "Team setup required", table: "Library", bundle: .module) + } + } +} diff --git a/apps/maple-ios/Sources/MapleAppUI/Library/LibraryUsageTile.swift b/apps/maple-ios/Sources/MapleAppUI/Library/LibraryUsageTile.swift new file mode 100644 index 000000000..262f003b4 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Library/LibraryUsageTile.swift @@ -0,0 +1,97 @@ +#if os(iOS) + import MapleAppCore + import SwiftUI + + /// Read-only billing summary shown in Settings. It has no purchase or pricing action. + public struct LibraryUsageTile: View { + private let account: AccountEntitlements + @Environment(\.locale) private var locale + @Environment(\.calendar) private var calendar + + public init(account: AccountEntitlements) { self.account = account } + + public var body: some View { + let summary = account.billing.value?.usageSummary(calendar: calendar) + VStack(alignment: .leading, spacing: 6) { + ViewThatFits(in: .horizontal) { + HStack(spacing: 6) { + plan(summary?.planLabel ?? .loading) + if let meter = summary?.meter { + separator + Text(verbatim: LibraryUsagePresentation.percent(meter.roundedPercentUsed, locale: locale)) + .fontWeight(.semibold).monospacedDigit() + separator + reset(meter.reset) + } + } + VStack(alignment: .leading, spacing: 4) { + plan(summary?.planLabel ?? .loading) + if let meter = summary?.meter { + Text(verbatim: LibraryUsagePresentation.percent(meter.roundedPercentUsed, locale: locale)) + .fontWeight(.semibold).monospacedDigit() + reset(meter.reset) + } + } + } + if let meter = summary?.meter { + GeometryReader { geometry in + Capsule().fill(.quaternary) + .overlay(alignment: .leading) { + Capsule().fill( + LinearGradient( + colors: [ + MapleAccent.prominent, Color(red: 0.9076, green: 0.43804, blue: 0.2524), + ], + startPoint: .leading, endPoint: .trailing) + ) + .frame(width: geometry.size.width * meter.percentUsed / 100) + } + } + .frame(height: 4).accessibilityHidden(true) + if let credits = meter.creditBalance { + Text(verbatim: LibraryUsagePresentation.credits(credits, locale: locale)) + .monospacedDigit() + .foregroundStyle(MapleAccent.adaptive(light: 0x7B_8F_4A, dark: 0x87_A2_53)) + } + } + } + .font(.caption2) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(MapleSurface.card, in: RoundedRectangle(cornerRadius: 12)) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("settings.usage") + } + + private func plan(_ label: BillingUsageSummary.PlanLabel) -> some View { + Text(verbatim: LibraryUsagePresentation.plan(label, locale: locale)) + .fontWeight(.semibold).textCase(.uppercase) + .padding(.horizontal, 6).padding(.vertical, 3) + .background(.quaternary, in: Capsule()) + .overlay(Capsule().strokeBorder(MapleSurface.border.opacity(0.5))) + } + + private var separator: some View { + Text(verbatim: "·").foregroundStyle(.tertiary).accessibilityHidden(true) + } + + private func reset(_ value: BillingUsageReset) -> some View { + Text(verbatim: LibraryUsagePresentation.reset(value, locale: locale, calendar: calendar)) + .foregroundStyle(.secondary) + } + } + + public struct LibraryAccountAttentionBadge: View { + private let account: AccountEntitlements + public init(account: AccountEntitlements) { self.account = account } + + public var body: some View { + if let attention = LibraryAccountAttention(team: account.team.value) { + Image(systemName: "exclamationmark.circle.fill") + .foregroundStyle(attention == .usagePaused ? Color.red : Color.orange) + .accessibilityLabel(Text(verbatim: attention.accessibilityLabel)) + .accessibilityIdentifier("library.settings-attention") + } + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Library/LibraryView.swift b/apps/maple-ios/Sources/MapleAppUI/Library/LibraryView.swift new file mode 100644 index 000000000..b794ebead --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Library/LibraryView.swift @@ -0,0 +1,680 @@ +#if os(iOS) + import MapleAppCore + import MapleSDK + import Observation + import SwiftUI + + private enum LibraryDrawerDestination: Hashable { + case settings + case project(String) + } + + /// Compact navigation uses this full-scene root beneath the retained chat + /// page. Regular widths retain the NavigationSplitView sidebar. + public struct LibraryView: View { + @Bindable private var library: LibraryModel + @State private var models: LibraryMenuModel + @State private var drag: LibraryChatDragContext + @State private var search = "" + @State private var projectDestination: ProjectSheetDestination? + @State private var createdProjectID: String? + @State private var showingSettings = false + @State private var selecting = false + @State private var drawerPath: [LibraryDrawerDestination] = [] + @FocusState private var searchFocused: Bool + @AccessibilityFocusState private var closeFocused: Bool + private let makeInstructionEditor: @MainActor (InstructionDestination) -> InstructionEditor + private let onSelectChat: @MainActor (String) -> Void + private let onNewChat: @MainActor (String?) -> Void + private let selectedChatID: String? + private let settings: @MainActor () -> AnyView + private let settingsFullScreen: Bool + private let settingsPresented: Binding? + private let onSettingsDismiss: (@MainActor () -> Void)? + private let supplementary: (@MainActor () -> AnyView)? + private let onClose: (@MainActor () -> Void)? + private let drawerNavigation: Bool + private let compactRootBar: Bool + private let navigationAvailable: Bool + private let navigationFocusAvailable: Bool? + private var focusAvailable: Bool { navigationFocusAvailable ?? navigationAvailable } + private let onDrawerDestinationChanged: (@MainActor (Bool) -> Void)? + private let projectExpansion: LibraryProjectExpansionController? + private let onManageProjects: (@MainActor (String?) -> Void)? + private let account: AccountEntitlements? + private let conversationActivity: @MainActor (String) -> ConversationActivity + private let projectActivity: @MainActor (String) -> ConversationActivity + + public init( + library: LibraryModel, makeLibrary: @escaping @MainActor () -> LibraryModel, + makeInstructionEditor: @escaping @MainActor (InstructionDestination) -> InstructionEditor, + onSelectChat: @escaping @MainActor (String) -> Void, + onNewChat: @escaping @MainActor (String?) -> Void, + selectedChatID: String? = nil, + settings: @escaping @MainActor () -> AnyView, + account: AccountEntitlements? = nil, + conversationActivity: @escaping @MainActor (String) -> ConversationActivity = { _ in .idle }, + projectActivity: @escaping @MainActor (String) -> ConversationActivity = { _ in .idle }, + projectExpansion: LibraryProjectExpansionController? = nil, + menu: LibraryMenuModel? = nil, + onManageProjects: (@MainActor (String?) -> Void)? = nil, + onClose: (@MainActor () -> Void)? = nil, + settingsFullScreen: Bool = false, + settingsPresented: Binding? = nil, + onSettingsDismiss: (@MainActor () -> Void)? = nil, + drawerNavigation: Bool = false, + compactRootBar: Bool = false, + navigationAvailable: Bool = true, + navigationFocusAvailable: Bool? = nil, + onDrawerDestinationChanged: (@MainActor (Bool) -> Void)? = nil, + supplementary: (@MainActor () -> AnyView)? = nil + ) { + self.library = library; self.makeInstructionEditor = makeInstructionEditor + self.onSelectChat = onSelectChat; self.onNewChat = onNewChat; self.settings = settings; + self.selectedChatID = selectedChatID + self.account = account + self.conversationActivity = conversationActivity; self.projectActivity = projectActivity + self.onClose = onClose; self.settingsFullScreen = settingsFullScreen + self.settingsPresented = settingsPresented; self.onSettingsDismiss = onSettingsDismiss + self.supplementary = supplementary + self.drawerNavigation = drawerNavigation; self.navigationAvailable = navigationAvailable + self.compactRootBar = compactRootBar + self.navigationFocusAvailable = navigationFocusAvailable + self.onDrawerDestinationChanged = onDrawerDestinationChanged + self.projectExpansion = projectExpansion?.scope == library.scope ? projectExpansion : nil + self.onManageProjects = onManageProjects + if let menu, menu.scope == library.scope { + _models = State(initialValue: menu) + } else { + _models = State(initialValue: LibraryMenuModel(root: library, makeLibrary: makeLibrary)) + } + _drag = State(initialValue: LibraryChatDragContext(scope: library.scope)) + } + public var body: some View { + NavigationStack(path: $drawerPath) { + Group { + List { + Section { + Button { + selectNewChat(nil) + } label: { + Label { + libraryText("New chat") + } icon: { + Image(systemName: "square.and.pencil").foregroundStyle(MapleAccent.action) + } + .frame(minHeight: 44).contentShape(Rectangle()) + } + .buttonStyle(.plain).foregroundStyle(.primary) + .accessibilityIdentifier("library.new-chat") + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } + .listSectionMargins(.vertical, 0) + if let supplementary { supplementary() } + if showsNoSearchResults { + VStack(alignment: .leading, spacing: 4) { + Text( + String( + localized: "No chats found matching \"%@\"", + defaultValue: "No chats found matching \"\(trimmedSearch)\"", + table: "Library", bundle: .module)) + libraryText("Try a different search term").font(.footnote) + } + .foregroundStyle(.secondary) + .padding(.vertical, 8) + .accessibilityIdentifier("library.search.empty") + .listRowSeparator(.hidden).listRowBackground(Color.clear) + } else { + Section { + ForEach(visibleProjects, id: \.id) { project in + if let projectModel = models.projects[project.id] { + ProjectFolderView( + project: project, model: projectModel, search: search, + onSelectChat: selectChat, onNewChat: selectNewChat, + selectedChatID: selectedChatID, + conversationActivity: conversationActivity, + projectActivity: projectActivity(project.id), + onViewProject: viewProject, + onManageProject: manageProject, + onMutation: refresh, selectionLibrary: library, selecting: $selecting, + drag: drag, dragEnabled: navigationAvailable, + expanded: Binding( + get: { + projectExpansion?.isExpanded(id: project.id) + ?? models.expandedProjects.contains(project.id) + }, + set: { value in + if let projectExpansion { + projectExpansion.setExpanded(id: project.id, expanded: value) + } else if value { + models.expandedProjects.insert(project.id) + } else { + models.expandedProjects.remove(project.id) + } + })) + } + } + } header: { + HStack { + libraryText("Projects").font(.headline).foregroundStyle(.primary) + Spacer() + Button { + suspendSearch() + projectDestination = .create + } label: { + Image(systemName: "folder.badge.plus").foregroundStyle(MapleAccent.action) + .frame(minWidth: 44, minHeight: 44) + } + .buttonStyle(.plain) + .disabled(library.projects.count >= 10) + .accessibilityLabel(libraryText("New project")) + .accessibilityHint( + libraryText("Maximum of 10 projects reached"), + isEnabled: library.projects.count >= 10 + ) + .accessibilityIdentifier("library.new-project") + } + .listRowInsets(.top, 8).listRowInsets(.bottom, 0) + } + .textCase(nil) + .listSectionMargins(.vertical, 0) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + if drag.isDragging || !models.pinned.visibleConversations.isEmpty || models.pinned.hasMore + || models.pinned.issue != nil + { + Section { + conversationRows( + models.pinned, loadMoreID: "library.pinned.load-more", destination: .pinned) + } header: { + dropHeading("Pinned", destination: .pinned, id: "library.drop.pinned") + } + .textCase(nil) + .listSectionMargins(.vertical, 0) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } + Section { + conversationRows( + models.recent, loadMoreID: "library.recent.load-more", destination: .recent) + } header: { + dropHeading("Recent", destination: .recent, id: "library.drop.recent") + } + .textCase(nil) + .listSectionMargins(.vertical, 0) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } + if let issue = library.issue { LibraryIssueView(issue: issue) } + } + + .accessibilityIdentifier("library.sidebar-list") + .refreshable { await refresh() } + .allowsHitTesting(navigationAvailable) + .accessibilityHidden(!navigationAvailable) + .safeAreaInset(edge: .bottom, spacing: 0) { + searchControls + .padding(.horizontal, 16).padding(.vertical, 8) + .allowsHitTesting(navigationAvailable) + .accessibilityHidden(!navigationAvailable) + } + .listStyle(.plain) + .environment(\.defaultMinListRowHeight, 50) + .environment(\.defaultMinListHeaderHeight, 0) + // Row labels own their tap height; remove additional native + // insets instead of compressing those hit targets. + .listRowSpacing(0) + .listRowInsets(.vertical, 0) + .listRowInsets(.leading, 20).listRowInsets(.trailing, 12) + .scrollContentBackground(.hidden) + .background(MapleSurface.sidebar) + .navigationBarTitleDisplayMode(.inline) + .toolbar(removing: .sidebarToggle) + .toolbar { + if !usesCompactRootBar, onClose != nil, navigationAvailable { + ToolbarItem(placement: .topBarTrailing) { + closeControl + } + } + } + .onChange(of: search) { _, value in + models.setSearch(value) + } + .onChange(of: selecting) { _, _ in drag.cancel() } + .onChange(of: navigationAvailable) { _, value in + if !value { drag.cancel(); suspendSearch() } + } + .onDisappear { + drag.cancel(); suspendSearch() + } + .task(id: navigationAvailable) { + guard navigationAvailable else { return } + if drawerNavigation && focusAvailable && drawerPath.isEmpty { closeFocused = true } + await refresh() + } + .accessibilityAction(.escape) { + // Only the root drawer owns this action. Pushed editors + // retain their native Back/Cancel and discard safeguards. + if drawerNavigation && navigationAvailable && drawerPath.isEmpty { + suspendSearch(); onClose?() + } + } + + } + .safeAreaInset(edge: .top, spacing: 0) { + if drawerNavigation, let account { + TeamSeatMismatchNotice(account: account) + } + } + .toolbarVisibility( + navigationAvailable && (!usesCompactRootBar || !drawerPath.isEmpty) ? .visible : .hidden, + for: .navigationBar + ) + .navigationDestination(for: LibraryDrawerDestination.self) { destination in + switch destination { + case .settings: + if usesCompactRootBar { + settings().toolbarVisibility(.visible, for: .navigationBar) + } else { + settings() + } + case .project(let id): + if let project = library.projects.first(where: { $0.id == id }) { + ProjectView( + project: project, library: models.project(id), + makeInstructionEditor: makeInstructionEditor, + onSelectChat: selectChat, onNewChat: selectNewChat, + selectedChatID: selectedChatID, conversationActivity: conversationActivity, + onMutation: refresh, teamNoticeAccount: drawerNavigation ? account : nil + ) + .toolbarVisibility(.visible, for: .navigationBar) + } + + } + } + .modifier( + LibraryChatManagement( + library: library, selecting: $selecting, onMutation: refresh, + account: account, + onSettings: { + suspendSearch() + if settingsFullScreen { + settingsBinding.wrappedValue = true + } else { + drawerPath.append(.settings) + } + }, + compactRootBar: usesCompactRootBar, + nativeRootBarHidden: !navigationAvailable || !drawerPath.isEmpty, + nativeRootBarTrailing: onClose != nil && navigationAvailable ? AnyView(closeControl) : nil) + + ) + } + .onChange(of: drawerPath) { _, path in + // Detail has its own query over the shared project snapshot. + // Restore the menu owner's query after native Back, rather + // than capturing a mutable model value in the destination. + if path.isEmpty { models.setSearch(search) } + guard drawerNavigation else { return } + // Pushing clears root focus; native Back restores its own row. + if !path.isEmpty { closeFocused = false; suspendSearch() } + onDrawerDestinationChanged?(!path.isEmpty) + } + .onChange(of: library.projects.map(\.id)) { _, ids in + drawerPath.removeAll { destination in + if case .project(let id) = destination { return !ids.contains(id) } + return false + } + } + .onChange(of: focusAvailable) { _, available in + closeFocused = drawerNavigation && available && drawerPath.isEmpty + if !available { suspendSearch() } else if !search.isEmpty { models.setSearch(search) } + } + .sheet( + isPresented: projectSheetBinding, + onDismiss: { + let id = createdProjectID; createdProjectID = nil + Task { + await refresh() + if let id, library.isActive { viewProject(id) } + } + } + ) { + if let destination = projectDestination { + ProjectManagementSheet( + library: library, initialDestination: destination, + makeInstructionEditor: makeInstructionEditor, + isCurrent: { library.isActive && library.scope == models.scope }, + onCreatedProject: { id in + createdProjectID = id; projectDestination = nil + }) + } + } + .fullScreenCover(isPresented: settingsBinding, onDismiss: onSettingsDismiss) { + NavigationStack { + settings().toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + settingsBinding.wrappedValue = false + } label: { + Label { + Text("Close settings", tableName: "Library", bundle: .module) + } icon: { + Image(systemName: "xmark") + } + .labelStyle(.iconOnly) + .frame(minWidth: 44, minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(Text("Close settings", tableName: "Library", bundle: .module)) + .accessibilityIdentifier("library.settings-close") + } + } + } + } + } + + private var usesCompactRootBar: Bool { compactRootBar && drawerNavigation } + + @ViewBuilder private var closeControl: some View { + if let onClose, navigationAvailable { + Button { + suspendSearch(); onClose() + } label: { + Label { + Text("Close menu", tableName: "Library", bundle: .module) + } icon: { + Image(systemName: "xmark") + } + .labelStyle(.iconOnly) + .foregroundStyle(MapleAccent.action) + .frame(minWidth: 44, minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(Text("Close menu", tableName: "Library", bundle: .module)) + .accessibilityIdentifier("library.close") + .accessibilityFocused($closeFocused) + } + } + + private func selectChat(_ id: String) { + suspendSearch() + if drawerNavigation { drawerPath = [] } + onSelectChat(id) + } + private func selectNewChat(_ projectID: String?) { + suspendSearch() + if drawerNavigation { drawerPath = [] } + selecting = false; library.selectedIDs = [] + onNewChat(projectID) + } + private var settingsBinding: Binding { settingsPresented ?? $showingSettings } + private var projectSheetBinding: Binding { + Binding(get: { projectDestination != nil }, set: { if !$0 { projectDestination = nil } }) + } + private func manageProject(_ id: String) { + suspendSearch() + if let onManageProjects { onManageProjects(id) } else { projectDestination = .manage(id) } + } + private func viewProject(_ id: String) { + guard library.isActive, library.projects.contains(where: { $0.id == id }) else { return } + suspendSearch(); drag.cancel(); selecting = false; library.selectedIDs = [] + _ = models.project(id) + drawerPath.append(.project(id)) + } + private func dismissSearch() { searchFocused = false } + private func suspendSearch() { dismissSearch(); models.cancelSearchLoading() } + private var searchControls: some View { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass").foregroundStyle(.secondary).accessibilityHidden(true) + TextField(text: $search) { libraryText("Search chats") } + .textInputAutocapitalization(.never).autocorrectionDisabled().submitLabel(.search) + .frame(minHeight: 44) + .contentShape(Rectangle()) + .focused($searchFocused) + .onSubmit { dismissSearch() } + .accessibilityIdentifier("library.search") + if !search.isEmpty { + Button { + search = "" + } label: { + Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) + .frame(minWidth: 44, minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(libraryText("Clear search")) + .accessibilityIdentifier("library.clear-search") + } + } + .padding(.horizontal, 16).padding(.vertical, 4) + // Glass alone leaves transparent gaps in the hit-test surface. + // The complete capsule owns taps, including its padded edges. + .background(MapleSurface.canvas, in: Capsule()) + .contentShape(Capsule()) + .onTapGesture { searchFocused = true } + .glassEffect(.regular.interactive(), in: .capsule) + .overlay { Capsule().strokeBorder(MapleSurface.border.opacity(0.7), lineWidth: 0.75) } + .shadow(color: .black.opacity(0.16), radius: 14, x: 0, y: 6) + } + private var visibleProjects: [ConversationProjectListItem] { + let query = trimmedSearch + return library.projects.filter { project in + query.isEmpty || project.name.localizedCaseInsensitiveContains(query) + || models.projects[project.id]?.conversations.contains { + LibraryModel.title($0).localizedCaseInsensitiveContains(query) + } == true + } + } + private var trimmedSearch: String { search.trimmingCharacters(in: .whitespacesAndNewlines) } + private var showsNoSearchResults: Bool { + !trimmedSearch.isEmpty && models.searchText == search && models.isPrepared + && !library.isLoadingProjects && library.issue == nil && visibleProjects.isEmpty + && models.pinned.hasLoadedConversations && !models.pinned.isLoading && models.pinned.issue == nil + && models.pinned.visibleConversations.isEmpty + && models.recent.hasLoadedConversations && !models.recent.isLoading && models.recent.issue == nil + && models.recent.visibleConversations.isEmpty + && library.projects.allSatisfy { project in + guard let model = models.projects[project.id] else { return false } + return model.hasLoadedConversations && !model.isLoading && model.issue == nil + } + } + private func dropHeading(_ title: String, destination: LibraryConversationDestination, id: String) -> some View + { + libraryText(title).font(.headline).foregroundStyle(.primary) + .frame(maxWidth: .infinity, minHeight: 24, alignment: .leading) + .listRowInsets(.top, 8).listRowInsets(.bottom, 0) + .accessibilityIdentifier(id) + .modifier( + LibraryChatDropTarget( + context: drag, library: library, destination: destination, + enabled: navigationAvailable && !selecting, onMutation: refresh)) + } + @ViewBuilder private func conversationRows( + _ model: LibraryModel, loadMoreID: String, destination: LibraryConversationDestination + ) -> some View { + ForEach(model.visibleConversations, id: \.id) { conversation in + LibrarySelectableConversationRow( + conversation: conversation, library: model, selectionLibrary: library, selecting: $selecting, + onSelect: selectChat, onMutation: refresh, dragContext: drag, + dragEnabled: navigationAvailable, dropDestination: destination, selectedChatID: selectedChatID, + activity: conversationActivity(conversation.id) + ) + .onScrollVisibilityChange(threshold: 0.1) { visible in + guard visible, destination == .recent, navigationAvailable, focusAvailable else { return } + Task { await model.loadMoreIfNeeded(visibleID: conversation.id) } + } + // Apply row traits to the visibility wrapper, matching the + // project rows rather than retaining List's default padding. + .listRowInsets(.vertical, 0) + .listRowInsets(.leading, 20).listRowInsets(.trailing, 12) + } + if model.hasMore && (destination != .recent || model.issue != nil || !search.isEmpty) { + Button { + Task { await model.loadMore() } + } label: { + libraryText("Load more") + }.disabled(model.isLoading).accessibilityIdentifier(loadMoreID) + } + if let issue = model.issue { LibraryIssueView(issue: issue) } + if destination == .recent, model.hasLoadedConversations, !model.isLoading, + model.visibleConversations.isEmpty, model.issue == nil + { + libraryText("No recent chats.").font(.footnote).foregroundStyle(.secondary) + .accessibilityIdentifier("library.recent.empty") + } + } + private func refresh() async { + models.setSearch(search) + await models.refresh { id in + projectExpansion?.isExpanded(id: id) ?? models.expandedProjects.contains(id) + } + } + } + + private struct ProjectFolderView: View { + let project: ConversationProjectListItem + @Bindable var model: LibraryModel + let search: String + let onSelectChat: @MainActor (String) -> Void + let onNewChat: @MainActor (String?) -> Void + let selectedChatID: String? + let conversationActivity: @MainActor (String) -> ConversationActivity + let projectActivity: ConversationActivity + let onViewProject: @MainActor (String) -> Void + let onManageProject: @MainActor (String) -> Void + let onMutation: @MainActor () async -> Void + @Bindable var selectionLibrary: LibraryModel + @Binding var selecting: Bool + let drag: LibraryChatDragContext + let dragEnabled: Bool + @Binding var expanded: Bool + private var showingChildren: Bool { + expanded || !search.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + var body: some View { + HStack(spacing: 8) { + Button { + expanded.toggle() + } label: { + HStack(spacing: 12) { + Image(systemName: "folder") + Text(project.name).fontWeight(.semibold).lineLimit(1) + ConversationActivityIndicator(activity: folderActivity) + Spacer(minLength: 0) + } + .frame(minHeight: 48).contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("library.project." + project.id) + .accessibilityLabel(conversationActivityLabel(title: project.name, activity: folderActivity)) + .accessibilityValue(libraryText(showingChildren ? "Expanded" : "Collapsed")) + .accessibilityAction(named: libraryText("View project")) { onViewProject(project.id) } + .accessibilityAction(named: libraryText("Manage project")) { onManageProject(project.id) } + .contextMenu { + Button { + onViewProject(project.id) + } label: { + libraryText("View project") + } + .accessibilityIdentifier("library.project.view." + project.id) + Button { + onManageProject(project.id) + } label: { + libraryText("Manage project") + } + .accessibilityIdentifier("library.project.manage." + project.id) + } + Button { + onNewChat(project.id) + } label: { + Image(systemName: "square.and.pencil").foregroundStyle(MapleAccent.action) + .frame(minWidth: 44, minHeight: 44) + } + .buttonStyle(.plain) + .accessibilityLabel(libraryText("New chat in project")) + .accessibilityIdentifier("library.project.new-chat." + project.id) + } + .listRowInsets(.vertical, 0) + .listRowInsets(.leading, 20).listRowInsets(.trailing, 12) + .listRowSeparator(.hidden).listRowBackground(Color.clear) + #if DEBUG + .background(alignment: .topLeading) { + if ProcessInfo.processInfo.arguments.contains("--maple-library-local-cleanup"), + model.scope == selectionLibrary.scope, UUID(uuidString: project.id) != nil + { + // A read-only test probe admits exact, non-cascade cleanup + // from the raw project owner, including pinned children. + Text(verbatim: cleanupState).foregroundStyle(.clear).frame(width: 1, height: 1) + .accessibilityLabel(cleanupState) + .accessibilityIdentifier("library.cleanup-project." + project.id) + } + } + #endif + .modifier( + LibraryChatDropTarget( + context: drag, library: selectionLibrary, destination: .project(project.id), + enabled: dragEnabled && !selecting, onMutation: onMutation) + ) + .task(id: showingChildren) { + // A List may materialize the row after its saved disclosure + // is already open. Load that first page from visibility rather + // than depending on a change observed by this row instance. + guard showingChildren, !model.hasLoadedConversations else { return } + model.searchText = search + await model.load(.project(project.id)) + } + .onChange(of: search) { _, value in model.searchText = value } + if showingChildren { + ForEach(model.visibleConversations, id: \.id) { conversation in + LibrarySelectableConversationRow( + conversation: conversation, library: model, selectionLibrary: selectionLibrary, + selecting: $selecting, onSelect: onSelectChat, onMutation: onMutation, + leadingInset: 56, dragContext: drag, dragEnabled: dragEnabled, + dropDestination: .project(project.id), selectedChatID: selectedChatID, + activity: conversationActivity(conversation.id) + ) + .listRowInsets(.vertical, 0) + .listRowInsets(.leading, 56).listRowInsets(.trailing, 12) + } + if model.hasMore { + Button { + Task { await model.loadMore() } + } label: { + libraryText("Load more") + } + .disabled(model.isLoading) + .accessibilityIdentifier("library.project.load-more." + project.id) + .listRowInsets(.vertical, 0) + .listRowInsets(.leading, 56).listRowInsets(.trailing, 12) + .listRowSeparator(.hidden).listRowBackground(Color.clear) + } + if let issue = model.issue { LibraryIssueView(issue: issue) } + } + } + private var folderActivity: ConversationActivity { + guard !showingChildren else { return .idle } + let children = model.conversations.map { conversationActivity($0.id) } + if projectActivity == .running || children.contains(.running) { return .running } + if projectActivity == .unread || children.contains(.unread) { return .unread } + return .idle + } + #if DEBUG + private var cleanupState: String { + guard model.isActive, model.filter == .project(project.id) else { return "loading" } + if model.issue != nil { return "error" } + guard model.hasLoadedConversations, !model.isLoading else { return "loading" } + return model.conversations.isEmpty && !model.hasMore ? "empty" : "nonempty" + } + #endif + } + + /// Fixed app-owned keys are maintained in Library.xcstrings. Keep literals + /// as String until the explicit table lookup so extraction cannot misroute them. + func libraryText(_ key: String) -> Text { + Text(LocalizedStringKey(key), tableName: "Library", bundle: .module) + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Library/ProjectPickerView.swift b/apps/maple-ios/Sources/MapleAppUI/Library/ProjectPickerView.swift new file mode 100644 index 000000000..57bcec3fc --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Library/ProjectPickerView.swift @@ -0,0 +1,384 @@ +#if os(iOS) + import MapleAppCore + import SwiftUI + + /// The same project screens are used from Chat options and the main menu. + /// Selection is admitted by the presenting account and conversation owner. + enum ProjectSheetDestination: Hashable { + case projects, create, manage(String), rename(String), instructions(String) + + var permitsOutsideDismissal: Bool { + switch self { + case .projects, .manage: true + case .create, .rename, .instructions: false + } + } + } + + struct ProjectManagementSheet: View { + @Bindable var library: LibraryModel + var selectedProjectID: String? = nil + var initialDestination: ProjectSheetDestination = .projects + let makeInstructionEditor: @MainActor (InstructionDestination) -> InstructionEditor + let isCurrent: @MainActor () -> Bool + var onSelection: (@MainActor (String?) async -> Bool)? = nil + var onCreatedProject: (@MainActor (String) -> Void)? = nil + @Environment(\.dismiss) private var dismiss + @State private var path: [ProjectSheetDestination] = [] + + var body: some View { + NavigationStack(path: $path) { + ProjectSheetDestinationView( + destination: initialDestination, library: library, selectedProjectID: selectedProjectID, + makeInstructionEditor: makeInstructionEditor, isCurrent: isCurrent, + onSelection: selection, onCreatedProject: onCreatedProject, isRoot: true + ) + .navigationDestination(for: ProjectSheetDestination.self) { destination in + ProjectSheetDestinationView( + destination: destination, library: library, selectedProjectID: selectedProjectID, + makeInstructionEditor: makeInstructionEditor, isCurrent: isCurrent, + onSelection: selection, onCreatedProject: onCreatedProject) + } + } + .presentationDetents([.fraction(0.6), .fraction(0.98)]) + .presentationDragIndicator(.visible) + .presentationSizing(.page) + .presentationBackground(MapleSurface.canvas) + .background( + SheetOutsideTapDismissal { + if (path.last ?? initialDestination).permitsOutsideDismissal, !library.isMutating { dismiss() } + }.frame(width: 0, height: 0)) + } + + private var selection: (@MainActor (String?) async -> Bool)? { + guard let onSelection else { return nil } + return { id in + guard isCurrent(), await onSelection(id), isCurrent() else { return false } + dismiss(); return true + } + } + } + + struct ProjectSheetDestinationView: View { + let destination: ProjectSheetDestination + @Bindable var library: LibraryModel + let selectedProjectID: String? + let makeInstructionEditor: @MainActor (InstructionDestination) -> InstructionEditor + let isCurrent: @MainActor () -> Bool + let onSelection: (@MainActor (String?) async -> Bool)? + var onCreatedProject: (@MainActor (String) -> Void)? = nil + var isRoot = false + + @ViewBuilder var body: some View { + switch destination { + case .projects: + ProjectPickerView( + library: library, selectedProjectID: selectedProjectID, + isCurrent: isCurrent, onSelection: onSelection) + case .create: + ProjectNameForm( + library: library, projectID: nil, isCurrent: isCurrent, onCreated: onSelection, + onCreatedProject: onSelection == nil ? onCreatedProject : nil, isRoot: isRoot) + case .manage(let id): + ProjectManagementView(library: library, projectID: id, isCurrent: isCurrent) + case .rename(let id): + ProjectNameForm(library: library, projectID: id, isCurrent: isCurrent, onCreated: nil, isRoot: isRoot) + case .instructions(let id): + ProjectInstructionsDestination( + projectID: id, makeInstructionEditor: makeInstructionEditor, isCurrent: isCurrent) + } + } + } + + struct ProjectPickerView: View { + @Bindable var library: LibraryModel + let selectedProjectID: String? + let isCurrent: @MainActor () -> Bool + let onSelection: (@MainActor (String?) async -> Bool)? + @State private var selecting = false + + var body: some View { + List { + if onSelection != nil { selectionRow(name: libraryText("No project"), id: nil) } + ForEach(library.projects, id: \.id) { project in + if onSelection != nil { + HStack(spacing: 8) { + selectionRow(name: Text(verbatim: project.name), id: project.id) + NavigationLink(value: ProjectSheetDestination.manage(project.id)) { + Image(systemName: "chevron.right") + .font(.body.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(minWidth: 48, minHeight: 48) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .navigationLinkIndicatorVisibility(.hidden) + .accessibilityLabel(libraryText("Project actions")) + .accessibilityIdentifier("project-picker.actions." + project.id) + } + } else { + NavigationLink(value: ProjectSheetDestination.manage(project.id)) { + Label { + Text(verbatim: project.name).foregroundStyle(.primary) + } icon: { + Image(systemName: "folder").foregroundStyle(MapleAccent.foreground) + } + .frame(minHeight: 44) + } + .accessibilityIdentifier("project-picker.manage." + project.id) + } + } + if let issue = library.issue { LibraryIssueView(issue: issue) } + } + .listStyle(.plain) + .listRowBackground(Color.clear) + .scrollContentBackground(.hidden) + .background(MapleSurface.canvas) + .navigationTitle(libraryText("Projects")) + .navigationBarTitleDisplayMode(.inline) + .toolbarVisibility(.visible, for: .navigationBar) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + NavigationLink(value: ProjectSheetDestination.create) { + Image(systemName: "plus") + .frame(minWidth: 44, minHeight: 44) + .contentShape(Rectangle()) + } + .accessibilityLabel(libraryText("New project")) + .accessibilityIdentifier("project-picker.new-project") + } + } + .disabled(!isCurrent() || library.isMutating || selecting) + .task { if isCurrent() { await library.loadProjects() } } + .accessibilityIdentifier("project-picker.screen") + } + + private func selectionRow(name: Text, id: String?) -> some View { + Button { + guard isCurrent(), !selecting, let onSelection else { return } + selecting = true + Task { @MainActor in + _ = await onSelection(id) + if isCurrent() { selecting = false } + } + } label: { + HStack(spacing: 12) { + Image(systemName: id == nil ? "tray" : "folder") + .foregroundStyle(MapleAccent.foreground) + name.foregroundStyle(.primary).lineLimit(2) + Spacer(minLength: 8) + if selectedProjectID == id { + Image(systemName: "checkmark").foregroundStyle(MapleAccent.foreground) + } + } + .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityAddTraits(selectedProjectID == id ? .isSelected : []) + .accessibilityIdentifier("project-picker.project." + (id ?? "none")) + } + } + + struct ProjectNameForm: View { + @Bindable var library: LibraryModel + let projectID: String? + let isCurrent: @MainActor () -> Bool + let onCreated: (@MainActor (String?) async -> Bool)? + var onCreatedProject: (@MainActor (String) -> Void)? = nil + var initialName: String? = nil + var onRenamed: (@MainActor (String) -> Void)? = nil + let isRoot: Bool + @Environment(\.dismiss) private var dismiss + @State private var name = "" + @State private var submitting = false + @State private var loaded = false + @State private var visible = false + @State private var originalName = "" + @State private var validation: String? + @FocusState private var nameFocused: Bool + + var body: some View { + Form { + Section { + TextField(text: $name) { libraryText("Project name") } + .focused($nameFocused) + .submitLabel(.done) + .onSubmit { submit() } + .accessibilityIdentifier("project-editor.name") + } + if let validation { + libraryText(validation).foregroundStyle(MapleRedText()) + .accessibilityIdentifier("project-editor.validation") + } + if let issue = library.issue { LibraryIssueView(issue: issue) } + } + .scrollContentBackground(.hidden) + .background(MapleSurface.canvas) + .navigationTitle(libraryText(projectID == nil ? "New project" : "Rename project")) + .navigationBarTitleDisplayMode(.inline) + .toolbarVisibility(.visible, for: .navigationBar) + .toolbar { + if isRoot { + ToolbarItem(placement: .cancellationAction) { + Button { + dismiss() + } label: { + libraryText("Cancel") + } + .disabled(submitting) + .accessibilityIdentifier("project-editor.cancel") + } + } + ToolbarItem(placement: .confirmationAction) { + Button { + submit() + } label: { + libraryText(projectID == nil ? "Create" : "Save") + } + .disabled(trimmedName.isEmpty || submitting || library.isMutating || !isCurrent()) + .accessibilityIdentifier("project-editor.save") + } + } + .disabled(submitting || !isCurrent()) + .interactiveDismissDisabled(submitting) + .onAppear { visible = true } + .onDisappear { visible = false } + .onChange(of: name) { _, _ in validation = nil } + .task { + guard !loaded, isCurrent() else { return } + if let projectID { + name = initialName ?? library.projects.first(where: { $0.id == projectID })?.name ?? "" + originalName = name.trimmingCharacters(in: .whitespacesAndNewlines) + } + loaded = true; nameFocused = true + } + } + + private var trimmedName: String { name.trimmingCharacters(in: .whitespacesAndNewlines) } + + private func submit() { + guard visible, isCurrent(), !submitting, !library.isMutating, !trimmedName.isEmpty else { return } + let value = trimmedName + if projectID != nil, value == originalName { + validation = "Please enter a different project name."; return + } + validation = nil + submitting = true; nameFocused = false + Task { @MainActor in + if let projectID { + await library.renameProject(id: projectID, name: value) + guard visible, isCurrent() else { return } + submitting = false + if library.issue == nil { onRenamed?(value); dismiss() } + } else { + let project = await library.createProject(name: value) + guard visible, isCurrent() else { return } + if let project { + if let onCreated { + let selected = await onCreated(project.id) + // A membership failure leaves the newly created + // project available to retry from the project list. + if !selected, visible, isCurrent() { dismiss() } + } else if let onCreatedProject { + onCreatedProject(project.id); dismiss() + } else { + dismiss() + } + } + if isCurrent() { submitting = false } + } + } + } + } + + private struct ProjectManagementView: View { + @Bindable var library: LibraryModel + let projectID: String + let isCurrent: @MainActor () -> Bool + @Environment(\.dismiss) private var dismiss + @State private var deleting = false + + var body: some View { + List { + Section { + NavigationLink(value: ProjectSheetDestination.rename(projectID)) { + Label { + libraryText("Rename project") + } icon: { + Image(systemName: "pencil") + } + } + .accessibilityIdentifier("project-editor.rename") + NavigationLink(value: ProjectSheetDestination.instructions(projectID)) { + Label { + libraryText("Project instructions") + } icon: { + Image(systemName: "text.alignleft") + } + } + .accessibilityIdentifier("project-editor.instructions") + } + Section { + Button(role: .destructive) { + if isCurrent() { deleting = true } + } label: { + Label { + libraryText("Delete project") + } icon: { + Image(systemName: "trash") + } + } + .accessibilityIdentifier("project-editor.delete") + } + if let issue = library.issue { LibraryIssueView(issue: issue) } + } + .listStyle(.plain) + .listRowBackground(Color.clear) + .scrollContentBackground(.hidden) + .background(MapleSurface.canvas) + .navigationTitle(library.projects.first(where: { $0.id == projectID })?.name ?? "") + .navigationBarTitleDisplayMode(.inline) + .toolbarVisibility(.visible, for: .navigationBar) + .disabled(!isCurrent() || library.isMutating) + .sheet(isPresented: $deleting) { + NavigationStack { + DeleteLibraryContentView( + title: "Delete this project?", actionTitle: "Delete project", + explanation: Text( + String( + localized: + "This will permanently delete \"%@\" and any chats in it. Move chats out first if you want to keep them. This action cannot be undone.", + defaultValue: + "This will permanently delete \"\(library.projects.first(where: { $0.id == projectID })?.name ?? "")\" and any chats in it. Move chats out first if you want to keep them. This action cannot be undone.", + table: "Library", bundle: .module)) + ) { + guard isCurrent() else { return .retired } + await library.deleteProject(id: projectID) + guard isCurrent() else { return .retired } + if library.issue == nil { deleting = false; dismiss() } + return library.issue + } + } + } + } + } + + private struct ProjectInstructionsDestination: View { + let projectID: String + let makeInstructionEditor: @MainActor (InstructionDestination) -> InstructionEditor + let isCurrent: @MainActor () -> Bool + @State private var editor: InstructionEditor? + + var body: some View { + Group { + if let editor { InstructionEditorView(editor: editor) } else { Color.clear.accessibilityHidden(true) } + } + .toolbarVisibility(.visible, for: .navigationBar) + .task { + guard editor == nil, isCurrent() else { return } + editor = makeInstructionEditor(.project(projectID)) + } + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Library/ProjectView.swift b/apps/maple-ios/Sources/MapleAppUI/Library/ProjectView.swift new file mode 100644 index 000000000..bccc28464 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Library/ProjectView.swift @@ -0,0 +1,220 @@ +#if os(iOS) + import MapleAppCore + import MapleSDK + import SwiftUI + + public struct ProjectView: View { + private let project: ConversationProjectListItem + @Bindable private var library: LibraryModel + private let makeInstructionEditor: @MainActor (InstructionDestination) -> InstructionEditor + private let onSelectChat: @MainActor (String) -> Void + private let onNewChat: @MainActor (String?) -> Void + private let selectedChatID: String? + private let conversationActivity: @MainActor (String) -> ConversationActivity + private let onMutation: (@MainActor () async -> Void)? + private let teamNoticeAccount: AccountEntitlements? + @Environment(\.dismiss) private var dismiss + @State private var renamed: String? + @State private var renaming = false + @State private var deleting = false + @State private var selecting = false + @State private var didInitialize = false + @State private var search = "" + @State private var editingInstructions = false + @State private var instructionEditor: InstructionEditor? + public init( + project: ConversationProjectListItem, library: LibraryModel, + makeInstructionEditor: @escaping @MainActor (InstructionDestination) -> InstructionEditor, + onSelectChat: @escaping @MainActor (String) -> Void, onNewChat: @escaping @MainActor (String?) -> Void, + selectedChatID: String? = nil, + conversationActivity: @escaping @MainActor (String) -> ConversationActivity = { _ in .idle }, + onMutation: (@MainActor () async -> Void)? = nil, + teamNoticeAccount: AccountEntitlements? = nil + ) { + self.project = project; self.library = library; self.makeInstructionEditor = makeInstructionEditor + self.onSelectChat = onSelectChat; self.onNewChat = onNewChat + self.selectedChatID = selectedChatID; self.conversationActivity = conversationActivity + self.onMutation = onMutation + self.teamNoticeAccount = teamNoticeAccount?.scope == library.scope ? teamNoticeAccount : nil + } + public var body: some View { + List { + Section { + Button { + onNewChat(project.id) + } label: { + Label { + Text( + String( + localized: "New chat in %@", defaultValue: "New chat in \(renamed ?? project.name)", + table: "Library", bundle: .module)) + } icon: { + Image(systemName: "square.and.pencil") + } + .frame(minHeight: 44) + } + .accessibilityIdentifier("library.project-detail.new-chat") + instructionsCard + } + .listRowSeparator(.hidden).listRowBackground(Color.clear) + Section { + ForEach(library.visibleConversations, id: \.id) { conversation in + LibrarySelectableConversationRow( + conversation: conversation, library: library, selectionLibrary: library, + selecting: $selecting, onSelect: onSelectChat, onMutation: changed, + selectedChatID: selectedChatID, activity: conversationActivity(conversation.id)) + } + if library.hasMore { + Button { + Task { await library.loadMore() } + } label: { + libraryText("Load more") + }.disabled(library.isLoading) + } + if !library.isLoading, library.conversations.isEmpty, library.issue == nil { + libraryText("No chats in this project yet.") + } + if let issue = library.issue { LibraryIssueView(issue: issue) } + } header: { + libraryText("Chats").font(.headline) + } + .textCase(nil).listRowSeparator(.hidden).listRowBackground(Color.clear) + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .background(MapleSurface.sidebar) + .accessibilityIdentifier("library.project-detail." + project.id) + .safeAreaInset(edge: .top, spacing: 0) { + if let teamNoticeAccount { TeamSeatMismatchNotice(account: teamNoticeAccount) } + } + .modifier( + LibraryChatManagement( + library: library, selecting: $selecting, onMutation: changed, showsWordmarkMenu: false, + selectionDeleteTitle: "Delete") + ) + .navigationTitle(renamed ?? project.name) + .navigationBarTitleDisplayMode(.inline) + .navigationDestination(isPresented: $editingInstructions) { + if let instructionEditor { + InstructionEditorView(editor: instructionEditor) + .safeAreaInset(edge: .top, spacing: 0) { + if let teamNoticeAccount { TeamSeatMismatchNotice(account: teamNoticeAccount) } + } + } + } + .searchable(text: $search, prompt: libraryText("Search chats")) + .onChange(of: search) { _, value in library.searchText = value } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button { + selecting.toggle(); if !selecting { library.selectedIDs = [] } + } label: { + libraryText(selecting ? "Done selecting" : "Select chats") + } + Button { + renaming = true + } label: { + libraryText("Rename project") + } + Button(role: .destructive) { + deleting = true + } label: { + libraryText("Delete project") + } + } label: { + Image(systemName: "ellipsis.circle").frame(minWidth: 44, minHeight: 44) + } + .accessibilityLabel(libraryText("Project actions")) + .accessibilityIdentifier("library.project-detail.actions") + .disabled(library.isMutating) + } + } + .sheet(isPresented: $renaming) { + NavigationStack { + ProjectNameForm( + library: library, projectID: project.id, isCurrent: { library.isActive }, onCreated: nil, + initialName: renamed ?? project.name, + onRenamed: { value in + renamed = value; Task { await changed() } + }, isRoot: true) + } + } + .sheet(isPresented: $deleting) { + NavigationStack { + DeleteLibraryContentView( + title: "Delete this project?", actionTitle: "Delete project", + explanation: Text( + String( + localized: + "This will permanently delete \"%@\" and any chats in it. Move chats out first if you want to keep them. This action cannot be undone.", + defaultValue: + "This will permanently delete \"\(renamed ?? project.name)\" and any chats in it. Move chats out first if you want to keep them. This action cannot be undone.", + table: "Library", bundle: .module)) + ) { + await library.deleteProject(id: project.id) + if library.issue == nil { await onMutation?(); dismiss() } + return library.issue + } + } + } + .task { + if !didInitialize { + library.selectedIDs = []; didInitialize = true + } + library.searchText = search + await library.load(.project(project.id)) + guard library.isActive else { return } + if instructionEditor == nil { instructionEditor = makeInstructionEditor(.project(project.id)) } + await instructionEditor?.load() + } + .refreshable { + await library.load(.project(project.id)) + await instructionEditor?.load() + } + } + private var instructionsCard: some View { + Button { + editingInstructions = true + } label: { + VStack(alignment: .leading, spacing: 8) { + HStack { + libraryText("Custom Instructions").font(.headline) + Spacer() + Image(systemName: "ellipsis").accessibilityHidden(true) + } + Text( + String( + localized: "Shared instructions for chats in %@", + defaultValue: "Shared instructions for chats in \(renamed ?? project.name)", + table: "Library", bundle: .module) + ) + .font(.footnote).foregroundStyle(.secondary) + if let instructionEditor, instructionEditor.isLoaded { + if instructionEditor.text.isEmpty { + libraryText("Set shared custom instructions that apply to chats in this project.") + .foregroundStyle(.secondary) + } else { + Text(verbatim: instructionEditor.text).foregroundStyle(.secondary) + } + } else if let issue = instructionEditor?.issue { + LibraryIssueView(issue: issue) + } else { + ProgressView().accessibilityLabel(libraryText("Loading instructions")) + } + } + .padding(12).frame(maxWidth: .infinity, minHeight: 44, alignment: .leading) + .background(MapleSurface.canvas, in: RoundedRectangle(cornerRadius: 12)) + .overlay { RoundedRectangle(cornerRadius: 12).strokeBorder(MapleSurface.border) } + .contentShape(Rectangle()) + } + .buttonStyle(.plain).foregroundStyle(.primary) + .disabled(instructionEditor == nil || !library.isActive) + .accessibilityIdentifier("library.project-detail.instructions") + } + private func changed() async { + await onMutation?() + if library.isActive { library.searchText = search } + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/LocalLifecycleFixture.swift b/apps/maple-ios/Sources/MapleAppUI/LocalLifecycleFixture.swift new file mode 100644 index 000000000..552e6e1f7 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/LocalLifecycleFixture.swift @@ -0,0 +1,80 @@ +#if os(iOS) && DEBUG + import Foundation + import MapleAppCore + import Observation + import SwiftUI + + /// Explicit local-profile test inputs. The normal root, account, navigation, + /// storage and lifecycle handlers remain in use. No microphone is opened, + /// speech service is requested or account credentials are changed by this fixture. + @MainActor @Observable final class LocalLifecycleFixtureDriver { + let capture = LocalLifecycleCapture() + let transcriber = LocalLifecycleTranscriber() + var online = true + var listeningProof: LocalListeningProof? + var onConnectivity: (@MainActor (Bool) -> Void)? + + func setOnline(_ value: Bool) { + online = value; onConnectivity?(value) + } + } + + @MainActor @Observable final class LocalLifecycleCapture: DictationCapturing { + private var owner: UUID? + private(set) var starts = 0 + private(set) var stops = 0 + private(set) var cancellations = 0 + func requestPermission() async -> Bool { true } + func start(id: UUID, maximumFrames: Int, onLimit: @escaping @MainActor @Sendable () -> Void) throws { + guard owner == nil else { throw DictationIssue.captureUnavailable } + owner = id; starts += 1 + } + func stop(id: UUID) throws -> Data { + guard owner == id else { throw DictationIssue.captureUnavailable } + owner = nil; stops += 1 + return Data(repeating: 0, count: 320) + } + func cancel(id: UUID) { + guard owner == id else { return } + owner = nil; cancellations += 1 + } + } + + actor LocalLifecycleTranscriber: DictationTranscribing { + private var pending: [CheckedContinuation] = [] + private var completionWaiting = false + func transcribe(wav: Data) async throws -> String { + if completionWaiting { completionWaiting = false; return "synthetic dictated text" } + return try await withCheckedThrowingContinuation { pending.append($0) } + } + func pendingCount() -> Int { pending.count } + @discardableResult func finish() -> Int { + if pending.isEmpty { completionWaiting = true; return 0 } + let active = pending; pending = [] + for continuation in active { continuation.resume(returning: "synthetic dictated text") } + return active.count + } + } + + struct LocalLifecycleFixtureControls: View { + let driver: LocalLifecycleFixtureDriver + var body: some View { + if let proof = driver.listeningProof { + LocalListeningFixtureControls(proof: proof, transcriber: driver.transcriber) + } else { + VStack(spacing: 4) { + Text( + "Starts \(driver.capture.starts), stops \(driver.capture.stops), cancelled \(driver.capture.cancellations)" + ) + .font(.caption).accessibilityIdentifier("lifecycle.capture-counts") + HStack { + Button(driver.online ? "Go offline" : "Go online") { driver.setOnline(!driver.online) } + .accessibilityIdentifier("lifecycle.connectivity") + Button("Complete synthetic transcript") { Task { await driver.transcriber.finish() } } + .accessibilityIdentifier("lifecycle.transcript") + }.font(.caption).buttonStyle(.bordered) + }.padding(4).background(.background) + } + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Localization/LocalLocalizationProof.swift b/apps/maple-ios/Sources/MapleAppUI/Localization/LocalLocalizationProof.swift new file mode 100644 index 000000000..4568b788b --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Localization/LocalLocalizationProof.swift @@ -0,0 +1,55 @@ +#if DEBUG && os(iOS) + import SwiftUI + import UIKit + + /// Read-only, data-free proof of the actual environment received by the normal + /// application. It never changes traits, settings, focus, layout or account state. + struct LocalLocalizationProof: UIViewRepresentable { + @Environment(\.dynamicTypeSize) private var typeSize + @Environment(\.legibilityWeight) private var weight + @Environment(\.layoutDirection) private var direction + + func makeUIView(context: Context) -> ProofLabel { + let view = ProofLabel() + view.font = .systemFont(ofSize: 8) + view.text = "Localization diagnostics" + view.isAccessibilityElement = true + view.accessibilityIdentifier = "localization.observed-traits" + view.isUserInteractionEnabled = false + return view + } + + func updateUIView(_ view: ProofLabel, context: Context) { + view.swiftAccessibility = typeSize.isAccessibilitySize + view.swiftBold = weight == .bold + view.swiftRTL = direction == .rightToLeft + view.publish() + } + + @MainActor final class ProofLabel: UILabel { + var swiftAccessibility = false + var swiftBold = false + var swiftRTL = false + + override func didMoveToWindow() { + super.didMoveToWindow() + publish() + } + + func publish() { + let values: [String: Bool] = [ + "swiftAccessibility": swiftAccessibility, + "uiAccessibility": traitCollection.preferredContentSizeCategory.isAccessibilityCategory, + "swiftBold": swiftBold, + "uiBold": traitCollection.legibilityWeight == .bold, + "systemBold": UIAccessibility.isBoldTextEnabled, + "swiftRTL": swiftRTL, + "uiRTL": effectiveUserInterfaceLayoutDirection == .rightToLeft, + ] + if let bytes = try? JSONEncoder().encode(values), let value = String(data: bytes, encoding: .utf8) { + accessibilityValue = value + } + } + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Localization/MapleStrings.swift b/apps/maple-ios/Sources/MapleAppUI/Localization/MapleStrings.swift new file mode 100644 index 000000000..0bd9d5337 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Localization/MapleStrings.swift @@ -0,0 +1,27 @@ +import Foundation + +/// Package-owned interface text must resolve in the UI resource bundle. Model, +/// account, and user content must bypass this helper and remain verbatim. +enum MapleStrings { + static func text(_ key: String.LocalizationValue, locale: Locale = .current) -> String { + String(localized: key, bundle: .module, locale: locale) + } + + static func queuedMessageCount(_ count: Int, locale: Locale = .current) -> String { + String( + localized: "chat.queued-message-count", defaultValue: "\(count) queued messages", + bundle: .module, locale: locale) + } + + static func imageCount(_ count: Int, locale: Locale = .current) -> String { + String( + localized: "chat.image-count", defaultValue: "\(count) images", + bundle: .module, locale: locale) + } + + static func externalLinkMessage(_ destination: String, locale: Locale = .current) -> String { + String( + localized: "browser.external-link-message", + defaultValue: "You’re leaving Maple content.\n\(destination)", bundle: .module, locale: locale) + } +} diff --git a/apps/maple-ios/Sources/MapleAppUI/MapleBrand.swift b/apps/maple-ios/Sources/MapleAppUI/MapleBrand.swift new file mode 100644 index 000000000..2f3783f5f --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/MapleBrand.swift @@ -0,0 +1,92 @@ +#if os(iOS) + import SwiftUI + import UIKit + + @MainActor enum MapleAccent { + // https://www.trymaple.ai/brand: Maple 500 is the core accent. Use + // supplied logo colors for contrast instead of a darker primary shade. + static let prominent = Color(red: 1, green: 151 / 255, blue: 113 / 255) + static let onProminent = Color(red: 23 / 255, green: 23 / 255, blue: 23 / 255) + static let foreground = prominent + static let action = actionColor() + + // System-owned filled controls can choose a white label. Use the + // documented dark ground there; custom primary buttons use peach with + // dark text. Both treatments preserve readable labels. + static let nativeFilled = onProminent + + nonisolated private static func actionColor() -> Color { + Color( + uiColor: UIColor { traits in + if traits.accessibilityContrast == .high { + return traits.userInterfaceStyle == .dark + ? UIColor(red: 247 / 255, green: 247 / 255, blue: 249 / 255, alpha: 1) + : UIColor(red: 23 / 255, green: 23 / 255, blue: 23 / 255, alpha: 1) + } + return UIColor(red: 1, green: 151 / 255, blue: 113 / 255, alpha: 1) + }) + } + + // UIKit may resolve this pure color provider during background rendering. + // Keep its closure free of inherited main-actor isolation. + nonisolated static func adaptive(light: UInt32, dark: UInt32) -> Color { + Color( + uiColor: UIColor { traits in + let value = traits.userInterfaceStyle == .dark ? dark : light + return UIColor( + red: CGFloat((value >> 16) & 255) / 255, + green: CGFloat((value >> 8) & 255) / 255, + blue: CGFloat(value & 255) / 255, alpha: 1) + }) + } + } + + @MainActor enum MapleSurface { + static let canvas = MapleAccent.adaptive(light: 0xFF_FF_FF, dark: 0x00_00_00) + static let card = MapleAccent.adaptive(light: 0xFA_FA_FA, dark: 0x26_26_26) + static let sidebar = canvas + static let border = MapleAccent.adaptive(light: 0xD1_D2_DC, dark: 0x5E_5F_6E) + static let displayText = MapleAccent.adaptive(light: 0x47_48_54, dark: 0xBA_BC_CB) + } + + @MainActor enum MapleTypography { + static let body = Font.custom("Manrope-Regular", size: 17, relativeTo: .body) + static let title = Font.custom("Manrope-Regular", size: 22, relativeTo: .title2).weight(.semibold) + static let button = Font.custom("Manrope-Regular", size: 17, relativeTo: .body).weight(.medium) + static let inlineCode = Font.custom("GeistMono-Regular", size: 16, relativeTo: .body) + static let code = Font.custom("GeistMono-Regular", size: 15, relativeTo: .footnote) + static let metadata = Font.custom("GeistMono-Regular", size: 12, relativeTo: .caption) + static let display = Font.custom("Array-Regular", size: 34, relativeTo: .largeTitle) + + } + + struct MaplePrimaryButtonStyle: ButtonStyle { + var compact = false + @Environment(\.isEnabled) private var isEnabled + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(MapleTypography.button) + .padding(.horizontal, compact ? 0 : 20) + .padding(.vertical, compact ? 0 : 8) + .frame(minWidth: 44, minHeight: 44) + .foregroundStyle(MapleAccent.onProminent) + .background(MapleAccent.prominent, in: Capsule()) + .scaleEffect(configuration.isPressed && !reduceMotion ? 0.95 : 1) + // Keep the hit region full-sized even while the pixels compress. + .frame(minWidth: 44, minHeight: 44) + .contentShape(Capsule()) + .opacity(isEnabled ? 1 : 0.4) + .animation(reduceMotion ? nil : .easeOut(duration: 0.2), value: configuration.isPressed) + } + } + + struct MapleWordmark: View { + var body: some View { + Image("MapleWordmark", bundle: .module) + .renderingMode(.template).resizable().scaledToFit() + .foregroundStyle(MapleAccent.adaptive(light: 0x17_17_17, dark: 0xF7_F7_F9)) + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/MapleRootView.swift b/apps/maple-ios/Sources/MapleAppUI/MapleRootView.swift new file mode 100644 index 000000000..dcdbe4d3a --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/MapleRootView.swift @@ -0,0 +1,292 @@ +#if os(iOS) + import MapleAppCore + import MapleSDK + import Security + import SwiftUI + import UIKit + + public struct MapleRootView: View { + @State private var environment: AppEnvironment? + @State private var accountLinkInbox = AccountLinkInbox() + #if DEBUG + @State private var deliveredLinkCount = 0 + #endif + @State private var configurationFailed = false + @State private var secureStorageFailed = false + @State private var keychainDiagnostic: OSStatus? + @Environment(\.scenePhase) private var scenePhase + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var launchTransition = MapleLaunchTransition() + + public init() {} + + public var body: some View { + #if DEBUG + if ProcessInfo.processInfo.arguments.contains("--maple-launch-transition-fixture") { + MapleLaunchTransitionFixture() + } else if ProcessInfo.processInfo.arguments.contains("--maple-renderer-fixture") { + RendererFixtureView() + } else if ProcessInfo.processInfo.arguments.contains("--maple-shipping-viewport-fixture") { + ShippingViewportFixtureView() + } else if ProcessInfo.processInfo.arguments.contains("--maple-library-controls-fixture") { + LibraryControlsFixtureView() + } else if ProcessInfo.processInfo.arguments.contains("--maple-auth-workflow-fixture") { + AuthWorkflowFixtureView() + } else if ProcessInfo.processInfo.arguments.contains("--maple-dictation-fixture") { + DictationFixtureView() + } else if ProcessInfo.processInfo.arguments.contains("--maple-entitlement-fixture") { + EntitlementFixtureView() + } else if ProcessInfo.processInfo.arguments.contains("--maple-chat-workflow-fixture") { + ChatWorkflowFixtureView() + } else if ProcessInfo.processInfo.arguments.contains("--maple-browser-fixture") { + BrowserFixtureView() + } else if ProcessInfo.processInfo.arguments.contains("--maple-player-fixture") { + ReplyPlayerFixtureView() + } else if ProcessInfo.processInfo.arguments.contains("--maple-composer-fixture") { + ComposerKeyboardFixtureView() + } else { + application + } + #else + application + #endif + } + + private var application: some View { + Group { + if let environment { + SessionRoot(environment: environment) + } else if configurationFailed { + ContentUnavailableView { + Label( + secureStorageFailed + ? MapleStrings.text("Secure storage unavailable") + : MapleStrings.text("Configuration unavailable"), + systemImage: "gear.badge.xmark") + } description: { + Text( + secureStorageFailed + ? MapleStrings.text("Maple couldn’t open protected account storage.") + : MapleStrings.text("This build needs a valid service configuration.")) + #if DEBUG + if let keychainDiagnostic { + Text("Keychain probe: \(keychainDiagnostic)").accessibilityIdentifier( + "startup.keychainStatus") + } + #endif + } + } else { + MapleLaunchSurface() + } + } + .safeAreaInset(edge: .top) { + #if DEBUG + if let environment, environment.configuration.profile == .local { + let traits = ProcessInfo.processInfo.arguments.contains("--maple-localization-proof") + let identity = ProcessInfo.processInfo.arguments.contains("--maple-library-local-cleanup") + if traits || (identity && environment.session.account != nil) { + // These read-only diagnostics must not obscure or intercept + // the shipping toolbar when the actual window becomes narrow. + HStack(spacing: 8) { + if traits { LocalLocalizationProof().frame(width: 120, height: 10) } + if identity, let scope = environment.session.account?.scope { + LibraryCleanupIdentityBadge(scope: scope) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: 12) + .clipped() + .allowsHitTesting(false) + } + } + if ProcessInfo.processInfo.arguments.contains("--maple-account-link-fixture"), + environment?.configuration.profile == .local + { + Text("Delivered links \(deliveredLinkCount)").font(.caption2) + .accessibilityIdentifier("account-link.delivered") + } + if let fixture = environment?.lifecycleFixture, + !ProcessInfo.processInfo.arguments.contains("--maple-local-listening-fixture") + { + LocalLifecycleFixtureControls(driver: fixture) + .accessibilityHidden(environment?.menuPresented == true) + } + #endif + } + .font(MapleTypography.body) + .tint(MapleAccent.foreground) + .background(MapleSurface.canvas.ignoresSafeArea()) + .environment(\.mapleLaunchTransition, launchTransition) + .overlay { + if !launchTransition.finished { MapleLaunchOverlay(launch: launchTransition) } + } + .onChange(of: environment?.readyScope) { _, scope in + launchTransition.accountReady( + scope != nil && scope == environment?.session.account?.scope, + reduceMotion: reduceMotion) + } + .onChange(of: environment?.session.phase) { _, phase in + if phase == .signedOut || phase == .unavailable { launchTransition.finishWithoutChat() } + } + .onChange(of: configurationFailed) { _, failed in + if failed { launchTransition.finishWithoutChat() } + } + .onChange(of: environment?.activationFailedScope) { _, scope in + if scope != nil, scope == environment?.session.account?.scope { + launchTransition.finishWithoutChat() + } + } + .task { + guard environment == nil, !configurationFailed else { return } + do { + let created = try AppEnvironment( + configuration: AppConfiguration.load(), accountLinkInbox: accountLinkInbox) + environment = created + await created.session.restore() + created.resumeAccountLinks() + } catch { + secureStorageFailed = (error as? TransportError) == .persistence + configurationFailed = true + #if DEBUG + if ProcessInfo.processInfo.arguments.contains("--maple-startup-diagnostic") { + keychainDiagnostic = SecItemCopyMatching( + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "cloud.opensecret.maple.native.dev.absent-diagnostic", + ] as CFDictionary, nil) + } + #endif + } + } + .onChange(of: scenePhase) { _, phase in + environment?.foreground = phase == .active + if phase == .active { Task { await environment?.signIn.foreground() } } + } + .onOpenURL(perform: receiveURL) + .onChange(of: environment?.session.phase) { _, _ in environment?.resumeAccountLinks() } + .onReceive( + NotificationCenter.default.publisher(for: UIApplication.protectedDataWillBecomeUnavailableNotification) + ) { _ in + Task { await environment?.protectedDataChanged(false) } + } + .onReceive( + NotificationCenter.default.publisher(for: UIApplication.protectedDataDidBecomeAvailableNotification) + ) { _ in + Task { await environment?.protectedDataChanged(true) } + } + } + + private func receiveURL(_ received: URL) { + guard let configuration = environment?.configuration ?? (try? AppConfiguration.load()) else { return } + var url = received + #if DEBUG + if configuration.profile == .local, + ProcessInfo.processInfo.arguments.contains("--maple-account-link-fixture") + { + deliveredLinkCount += 1 + url = LocalAccountLinkFixture.forward(received, configuration: configuration) ?? received + } + #endif + let acceptedURL = url + if let environment { + environment.receiveLink(acceptedURL) + } else if let link = IncomingAccountLink(url: url, configuration: configuration) { + _ = accountLinkInbox.offer(link, phase: .restoring, scope: nil) + } + } + } + + /// Text-only red for errors and destructive labels on standard app surfaces. + struct MapleRedText: ShapeStyle { + nonisolated func resolve(in environment: EnvironmentValues) -> Color { + environment.colorScheme == .dark ? .red : Color(red: 0.7, green: 0.1, blue: 0.1) + } + } + + /// Legible prompts on the standard light and dark authentication surfaces. + struct MapleFieldPrompt: ShapeStyle { + nonisolated func resolve(in environment: EnvironmentValues) -> Color { + environment.colorScheme == .dark ? Color(white: 0.65) : Color(white: 0.4) + } + } + + private struct SessionRoot: View { + @Bindable var environment: AppEnvironment + var body: some View { + Group { + switch environment.session.phase { + case .signedIn: + Group { + if environment.readyScope == environment.session.account?.scope, + environment.readyScope != nil + { + ChatScreen(environment: environment) + } else if let account = environment.session.account, + environment.activationFailedScope == account.scope + { + ContentUnavailableView { + Label( + MapleStrings.text("Unable to open your account"), + systemImage: "wifi.exclamationmark") + } description: { + Text( + MapleStrings.text( + "Your saved work is preserved. Try again when your connection and protected storage are available." + )) + } actions: { + Button(MapleStrings.text("Try again")) { + Task { await environment.activate(account) } + } + .buttonStyle(MaplePrimaryButtonStyle()) + .accessibilityIdentifier("startup.retry") + } + } else { + MapleLaunchSurface() + } + } + .task(id: environment.session.account?.scope) { + if let account = environment.session.account { await environment.activate(account) } + } + case .signedOut, .authenticating: + SignInView( + session: environment.session, coordinator: environment.signIn, + onPasswordReset: { environment.presentAccount(.reset) }) + case .restoring, .cleaningUp: + MapleLaunchSurface() + case .unavailable: + ContentUnavailableView { + Label(MapleStrings.text("Unable to open your account"), systemImage: "wifi.exclamationmark") + } description: { + if case .clock(let clock)? = environment.session.issue { + Text(AttestationClockText.message(clock)) + } else { + Text( + environment.session.issue == .cleanup + ? MapleStrings.text("Account cleanup must finish before you can sign in again.") + : MapleStrings.text( + "Your saved work is preserved. Try again when your connection and protected storage are available." + ) + ) + } + } actions: { + Button(MapleStrings.text("Try again")) { + Task { + await environment.session.restore() + } + }.buttonStyle(MaplePrimaryButtonStyle()) + if environment.session.issue != .cleanup { + Button(MapleStrings.text("Sign out"), role: .destructive) { + Task { await environment.session.signOut() } + } + } + } + } + } + .sheet(item: $environment.accountFlow) { flow in + AccountFlowView(flow: flow, session: environment.session) + } + .preferredColorScheme(environment.preferences?.value.theme.preferredColorScheme) + } + } + +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Navigation/BrowserFixtureView.swift b/apps/maple-ios/Sources/MapleAppUI/Navigation/BrowserFixtureView.swift new file mode 100644 index 000000000..e3ad5836d --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Navigation/BrowserFixtureView.swift @@ -0,0 +1,43 @@ +#if os(iOS) && DEBUG + import Foundation + import SwiftUI + + /// Synthetic local pages exercise the real link confirmation and browser. + struct BrowserFixtureView: View { + @State private var requested: URL? + @State private var text = "Keep this browser draft" + @State private var focused = false + @State private var owner = UUID() + @State private var presented = false + private var destination: URL? { + let arguments = ProcessInfo.processInfo.arguments + guard let index = arguments.firstIndex(of: "--maple-browser-url"), index + 1 < arguments.count, + let url = URL(string: arguments[index + 1]), url.scheme == "http", url.host == "127.0.0.1", + url.user == nil, url.password == nil, url.path == "/one" + else { return nil } + return url + } + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + Text(presented ? "Chat covered" : "Chat visible").accessibilityIdentifier( + "browser.fixture.visibility") + if let destination { + MessageView(text: "[Local reference](\(destination.absoluteString))") { requested = $0 } + } + Button("Unsupported link") { requested = URL(string: "file:///synthetic") } + .accessibilityIdentifier("browser.fixture.unsupported") + ComposerEditor( + text: $text, focused: $focused, ownerID: owner, + placeholder: "Synthetic browser draft", accessibilityIdentifier: "browser.fixture.draft", + onSubmit: {} + ) + .frame(minHeight: 80) + }.padding() + } + } + .externalLinks($requested) { presented = $0 } + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Navigation/ChatPresentationContext.swift b/apps/maple-ios/Sources/MapleAppUI/Navigation/ChatPresentationContext.swift new file mode 100644 index 000000000..56be51921 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Navigation/ChatPresentationContext.swift @@ -0,0 +1,92 @@ +#if os(iOS) + import SwiftUI + + private struct ChatPresentationContextKey: EnvironmentKey { + static let defaultValue: @MainActor @Sendable (UUID, Bool) -> Void = { _, _ in } + } + + extension EnvironmentValues { + var chatPresentationChanged: @MainActor @Sendable (UUID, Bool) -> Void { + get { self[ChatPresentationContextKey.self] } + set { self[ChatPresentationContextKey.self] = newValue } + } + } + + extension View { + /// Each sheet/picker owns a separate lease; dismissing one cannot expose + /// a chat still covered by another presentation or a replacement account. + func tracksChatPresentation(_ presented: Bool) -> some View { + modifier(ChatPresentationLease(presented: presented)) + } + + func chatFullScreenCover( + item: Binding, @ViewBuilder content: @escaping (Item) -> Cover + ) -> some View { + modifier(ChatFullScreenPresentation(item: item, makeCover: content)) + } + } + + private struct ChatFullScreenPresentation: ViewModifier { + @Binding var item: Item? + let makeCover: (Item) -> Cover + @State private var lease = ChatPresentationLeaseOwner() + @Environment(\.chatPresentationChanged) private var changed + @Environment(\.compactMenuPresentationDismissed) private var compactMenuPresentationDismissed + func body(content: Content) -> some View { + content + .fullScreenCover( + item: $item, + onDismiss: { + lease.update(item != nil, changed: changed) + // Binding changes can release the lease before native + // dismissal completes; notify even if it is already inactive. + compactMenuPresentationDismissed() + } + ) { value in + // Retain the lease with the presented content too: lazy + // transcript eviction must not release a still-visible cover. + RetainedPresentationContent(lease: lease, content: makeCover(value)) + } + .onChange(of: item != nil, initial: true) { _, value in lease.update(value, changed: changed) } + .onAppear { lease.update(item != nil, changed: changed) } + } + } + + private struct RetainedPresentationContent: View { + let lease: ChatPresentationLeaseOwner + let content: Content + var body: some View { content } + } + + private struct ChatPresentationLease: ViewModifier { + let presented: Bool + @State private var lease = ChatPresentationLeaseOwner() + @Environment(\.chatPresentationChanged) private var changed + func body(content: Content) -> some View { + content + .onChange(of: presented, initial: true) { _, value in lease.update(value, changed: changed) } + .onAppear { lease.update(presented, changed: changed) } + } + } + + /// A full-screen cover may hide its presenter without destroying its state. + /// Keep the lease until dismissal or actual presenter destruction. + @MainActor private final class ChatPresentationLeaseOwner { + private let id = UUID() + private var presented = false + private var changed: (@MainActor @Sendable (UUID, Bool) -> Void)? + func update(_ value: Bool, changed: @escaping @MainActor @Sendable (UUID, Bool) -> Void) { + self.changed = changed + // Layout candidates can appear repeatedly without presenting anything. + // Publish only a transition, never an initial or repeated inactive lease. + guard presented != value else { return } + presented = value + changed(id, value) + } + deinit { + guard presented, let changed else { return } + let id = id + Task { @MainActor in changed(id, false) } + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Navigation/CompactMenuNavigation.swift b/apps/maple-ios/Sources/MapleAppUI/Navigation/CompactMenuNavigation.swift new file mode 100644 index 000000000..bc7796722 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Navigation/CompactMenuNavigation.swift @@ -0,0 +1,1049 @@ +#if os(iOS) + import MapleAppCore + import Observation + import SwiftUI + import UIKit + + struct CompactMenuChatState: Equatable, Sendable { + var occluded = false + var returnRevision = 0 + var focusNewProjection = false + } + + enum CompactMenuChatReturn: Sendable { case navigation, cancelledPreview } + + /// This lease owns no controller. Hosting roots can retain it without a + /// controller → root → controller ownership cycle. + @MainActor @Observable final class CompactMenuLease { + private(set) var scope: AccountScope? + private(set) var storageOwner: UUID? + private var controllerOwner: UUID? + private(set) var menuVisible = false + private var occluded = false + private var returnRevision = 0 + private var changedProjection = false + private var focusNewProjection = false + + func activate(scope: AccountScope, storageOwner: UUID?, controllerOwner: UUID) { + self.scope = scope; self.storageOwner = storageOwner; self.controllerOwner = controllerOwner + menuVisible = false; occluded = false; returnRevision = 0 + changedProjection = false; focusNewProjection = false + } + + func isOwned(scope: AccountScope, controllerOwner: UUID) -> Bool { + self.scope == scope && self.controllerOwner == controllerOwner + } + + func updateProjection(scope: AccountScope, storageOwner: UUID?, controllerOwner: UUID) { + guard isOwned(scope: scope, controllerOwner: controllerOwner) else { return } + if self.storageOwner != storageOwner { + changedProjection = occluded + self.storageOwner = storageOwner + } + } + + func begin(scope: AccountScope, controllerOwner: UUID) { + guard isOwned(scope: scope, controllerOwner: controllerOwner) else { return } + occluded = true; menuVisible = false; focusNewProjection = false + } + + func showMenu(scope: AccountScope, controllerOwner: UUID) { + guard isOwned(scope: scope, controllerOwner: controllerOwner) else { return } + occluded = true; menuVisible = true + } + + func showChat(scope: AccountScope, controllerOwner: UUID) { + guard isOwned(scope: scope, controllerOwner: controllerOwner) else { return } + menuVisible = false; occluded = false + focusNewProjection = changedProjection; changedProjection = false + returnRevision += 1 + } + + func retire(scope: AccountScope, controllerOwner: UUID) { + guard isOwned(scope: scope, controllerOwner: controllerOwner) else { return } + self.scope = nil; storageOwner = nil; self.controllerOwner = nil + menuVisible = false; occluded = false + changedProjection = false; focusNewProjection = false + } + + func retireAccount(scope: AccountScope) { + guard self.scope == scope, let owner = controllerOwner else { return } + retire(scope: scope, controllerOwner: owner) + } + + // Each hosted projection keeps its own account and storage-owner fence. + // Temporary navigation occlusion belongs to the plain UIKit route. + func chatProjectionAccessibilityHidden(scope: AccountScope, storageOwner: UUID?) -> Bool { + guard self.scope == scope, self.storageOwner == storageOwner, controllerOwner != nil else { return true } + return false + } + + func chatNavigationAccessibilityHidden(scope: AccountScope, controllerOwner: UUID) -> Bool { + guard isOwned(scope: scope, controllerOwner: controllerOwner) else { return true } + return occluded + } + + func state(scope: AccountScope, storageOwner: UUID) -> CompactMenuChatState { + guard self.scope == scope, self.storageOwner == storageOwner else { return CompactMenuChatState() } + return CompactMenuChatState( + occluded: occluded, returnRevision: returnRevision, focusNewProjection: focusNewProjection) + } + } + + private struct CompactMenuChatStateKey: EnvironmentKey { + static let defaultValue: @MainActor @Sendable (AccountScope, UUID) -> CompactMenuChatState = { _, _ in + CompactMenuChatState() + } + } + + private struct CompactMenuPresentationDismissedKey: EnvironmentKey { + static let defaultValue: @MainActor @Sendable () -> Void = {} + } + + extension EnvironmentValues { + var compactMenuChatState: @MainActor @Sendable (AccountScope, UUID) -> CompactMenuChatState { + get { self[CompactMenuChatStateKey.self] } + set { self[CompactMenuChatStateKey.self] = newValue } + } + + var compactMenuPresentationDismissed: @MainActor @Sendable () -> Void { + get { self[CompactMenuPresentationDismissedKey.self] } + set { self[CompactMenuPresentationDismissedKey.self] = newValue } + } + } + + /// Compact-only outer navigation. Each child owns its existing SwiftUI + /// navigation path; the outer stack owns only Library ↔ Chat. + struct CompactMenuNavigation: UIViewControllerRepresentable { + let scope: AccountScope + let storageOwner: UUID? + let requestedMenu: Bool + let reduceMotion: Bool + let lease: CompactMenuLease + let library: Library + let chat: Chat + let isCurrent: @MainActor () -> Bool + let presentationBlocked: @MainActor () -> Bool + let requestMenu: @MainActor (Bool) -> Void + let blockPresentation: @MainActor (UUID, Bool) -> Void + let chatShown: @MainActor (CompactMenuChatReturn) -> Void + + func makeUIViewController(context: Context) -> CompactMenuController { + CompactMenuController( + scope: scope, storageOwner: storageOwner, lease: lease, + library: CompactMenuHostedContent(content: library, environment: context.environment), + chat: CompactMenuHostedContent(content: chat, environment: context.environment), + isCurrent: isCurrent, presentationBlocked: presentationBlocked, + requestMenu: requestMenu, blockPresentation: blockPresentation, chatShown: chatShown) + } + + func updateUIViewController(_ controller: CompactMenuController, context: Context) { + guard controller.scope == scope, controller.acceptsUpdates else { return } + lease.updateProjection(scope: scope, storageOwner: storageOwner, controllerOwner: controller.ownership) + withTransaction(context.transaction) { + controller.libraryHost.rootView = CompactMenuHostedContent( + content: library, environment: context.environment, + publishBar: controller.barPublisher(role: .library, storageOwner: storageOwner), + presentationDismissed: controller.presentationDismissalObserver()) + controller.chatHost.rootView = CompactMenuHostedContent( + content: chat, environment: context.environment, + publishBar: controller.barPublisher(role: .chat, storageOwner: storageOwner), + presentationDismissed: controller.presentationDismissalObserver()) + } + controller.update(requestedMenu: requestedMenu, reduceMotion: reduceMotion) + } + + static func dismantleUIViewController(_ controller: CompactMenuController, coordinator: ()) { + controller.retire() + } + } + + struct CompactMenuHostedContent: View { + let content: Content + let environment: EnvironmentValues + var publishBar: CompactMenuBarPublicationHandler = { _, _, _ in } + var presentationDismissed: @MainActor @Sendable () -> Void = {} + var body: some View { + var merged = environment + merged.compactMenuBarPublication = publishBar + merged.compactMenuPresentationDismissed = presentationDismissed + return content.environment(\.self, merged) + } + } + + enum CompactMenuBarRole: Equatable, Sendable { case library, chat } + + struct CompactMenuBarPublication { + let hidden: Bool + let title: String + let backIdentifier: String + let leading: AnyView? + let principal: AnyView? + let trailing: AnyView? + let environment: EnvironmentValues + } + + typealias CompactMenuBarPublicationHandler = + @MainActor @Sendable (CompactMenuBarRole, UUID, CompactMenuBarPublication?) -> Void + + private struct CompactMenuBarPublicationKey: EnvironmentKey { + static let defaultValue: CompactMenuBarPublicationHandler = { _, _, _ in } + } + + extension EnvironmentValues { + var compactMenuBarPublication: CompactMenuBarPublicationHandler { + get { self[CompactMenuBarPublicationKey.self] } + set { self[CompactMenuBarPublicationKey.self] = newValue } + } + } + + extension View { + func compactMenuRootBar( + role: CompactMenuBarRole, hidden: Bool = false, title: String = "", + backIdentifier: String = "chat.menu", leading: AnyView? = nil, + principal: AnyView? = nil, trailing: AnyView? = nil + ) -> some View { + background( + CompactMenuRootBarPublisher( + role: role, hidden: hidden, title: title, backIdentifier: backIdentifier, + leading: leading, principal: principal, trailing: trailing + ) + .frame(width: 0, height: 0)) + } + } + + /// Publish from the view that owns each binding, without borrowing or + /// replacing UIKit's navigation controller delegate. Offscreen roots retain + /// their publication; teardown can withdraw only its own identity. + private struct CompactMenuRootBarPublisher: UIViewControllerRepresentable { + let role: CompactMenuBarRole + let hidden: Bool + let title: String + let backIdentifier: String + let leading: AnyView? + let principal: AnyView? + let trailing: AnyView? + + func makeUIViewController(context: Context) -> Controller { Controller() } + + func updateUIViewController(_ controller: Controller, context: Context) { + controller.publish = context.environment.compactMenuBarPublication + controller.role = role + controller.publish( + role, controller.owner, + CompactMenuBarPublication( + hidden: hidden, title: title, backIdentifier: backIdentifier, + leading: leading, principal: principal, trailing: trailing, environment: context.environment)) + } + + static func dismantleUIViewController(_ controller: Controller, coordinator: ()) { + if let role = controller.role { controller.publish(role, controller.owner, nil) } + } + + final class Controller: UIViewController { + let owner = UUID() + var role: CompactMenuBarRole? + var publish: CompactMenuBarPublicationHandler = { _, _, _ in } + + override func loadView() { + let observer = UIView() + observer.backgroundColor = .clear + observer.isUserInteractionEnabled = false + view = observer + } + } + } + + /// A retained public hosting configuration gives bar controls their source + /// environment and stable SwiftUI state without orphaned child controllers. + @MainActor private final class CompactMenuBarSlot { + private var hosted: (UIView & UIContentView)? + + func update(_ content: AnyView?, environment: EnvironmentValues) -> UIView? { + guard let content else { clear(); return nil } + let configuration = UIHostingConfiguration { + content.environment(\.self, environment) + }.margins(.all, 0) + if let hosted { + hosted.configuration = configuration + } else { + hosted = configuration.makeContentView() + } + hosted?.backgroundColor = .clear + hosted?.sizeToFit() + return hosted + } + + func clear() { + // UIKit requires a content view's original configuration type. + // Removing and releasing this host releases its source environment. + hosted?.removeFromSuperview() + hosted = nil + } + } + + @MainActor private final class CompactMenuBarState { + var publisherOwner: UUID? + var hidden = false + var leadingItem: UIBarButtonItem? + var trailingItem: UIBarButtonItem? + let leading = CompactMenuBarSlot() + let principal = CompactMenuBarSlot() + let trailing = CompactMenuBarSlot() + } + + /// A route keeps its hosting child attached while UIKit moves only the plain + /// parent view during outer navigation transitions. + @MainActor private final class CompactMenuRouteController: UIViewController { + private let hostingController: UIViewController + private var hidesAccessibility: Bool + + init(hostingController: UIViewController, hidesAccessibility: Bool = false) { + self.hostingController = hostingController + self.hidesAccessibility = hidesAccessibility + super.init(nibName: nil, bundle: nil) + edgesForExtendedLayout = .all + extendedLayoutIncludesOpaqueBars = true + } + + required init?(coder: NSCoder) { return nil } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = UIColor(MapleSurface.canvas) + view.accessibilityElementsHidden = hidesAccessibility + addChild(hostingController) + let hostedView = hostingController.view! + hostedView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(hostedView) + NSLayoutConstraint.activate([ + hostedView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostedView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + hostedView.topAnchor.constraint(equalTo: view.topAnchor), + hostedView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + hostingController.didMove(toParent: self) + } + + func setAccessibilityHidden(_ hidden: Bool) { + hidesAccessibility = hidden + if let view = viewIfLoaded, view.accessibilityElementsHidden != hidden { + view.accessibilityElementsHidden = hidden + } + } + + override var childForStatusBarStyle: UIViewController? { hostingController } + override var childForStatusBarHidden: UIViewController? { hostingController } + override var childForHomeIndicatorAutoHidden: UIViewController? { hostingController } + override var childForScreenEdgesDeferringSystemGestures: UIViewController? { hostingController } + } + + /// Freeze only the hosted Chat child while UIKit moves its plain route. + /// The replica stays inside that route, so the system owns every frame of + /// its native interactive transition and release animation. + @MainActor private final class CompactMenuVisualProxy { + private(set) var view: UIView? + + init?(source: UIView, container: UIView, mayRestoreSource: @escaping (UIView) -> Bool) { + guard let window = source.window, container.window === window, + source.superview === container, !source.isHidden, source.transform.isIdentity, + mayRestoreSource(source) + else { return nil } + let frame = container.convert(source.bounds, from: source) + guard [frame.minX, frame.minY, frame.width, frame.height].allSatisfy({ $0.isFinite }), + frame.width > 0, frame.height > 0, + let replica = source.snapshotView(afterScreenUpdates: false) + else { return nil } + replica.frame = frame + replica.isUserInteractionEnabled = false + replica.isAccessibilityElement = false + replica.accessibilityElementsHidden = true + view = replica + container.addSubview(replica) + // Do not hide the touched hosting subtree during a native contact. + // The full-frame replica paints over its opaque canvas while the + // live child continues receiving UIKit's gesture/keyboard lifecycle. + } + + func remove() { + guard let view else { return } + view.layer.removeAllAnimations() + view.removeFromSuperview(); self.view = nil + } + } + + @MainActor + final class CompactMenuController: UINavigationController, + UINavigationControllerDelegate + { + let scope: AccountScope + let ownership = UUID() + private let presentationID = UUID() + let libraryHost: UIHostingController> + let chatHost: UIHostingController> + private let libraryRoute: CompactMenuRouteController + private let chatRoute: CompactMenuRouteController + private let lease: CompactMenuLease + private let isCurrent: @MainActor () -> Bool + private let presentationBlocked: @MainActor () -> Bool + private let requestMenu: @MainActor (Bool) -> Void + private let blockPresentation: @MainActor (UUID, Bool) -> Void + private let chatShown: @MainActor (CompactMenuChatReturn) -> Void + private let admission = UIPanGestureRecognizer() + private lazy var admissionDelegate = AdmissionDelegate(owner: self) + private weak var configuredEdge: UIGestureRecognizer? + private weak var configuredContent: UIGestureRecognizer? + private weak var cancelledEdge: UIGestureRecognizer? + private weak var cancelledContent: UIGestureRecognizer? + private var requestedMenu = false + private var reduceMotion = false + private var retired = false + private var reconcilePending = false + private var blockHeld = false + private var blockedContactActive = false + private weak var lastShown: UIViewController? + private var needsFreshChatCompletion = false + private var freshCompletionWithoutTransition = false + private var freshChatReturnReason = CompactMenuChatReturn.navigation + private var cancelledChatWidth: CGFloat? + private var operation: Operation? + private let libraryBar = CompactMenuBarState() + private let chatBar = CompactMenuBarState() + #if DEBUG + private let exposesNativeDiagnostics = ProcessInfo.processInfo.arguments.contains( + "--maple-shipping-viewport-fixture") + private let nativeDiagnostics = UILabel() + private var nativeAdmissionAttempts = 0 + private var nativeAdmissionAllowed = 0 + private var nativeEdgeEnabledAtAdmission = false + private var nativeWillShowCount = 0 + private var nativeDidShowCount = 0 + private var nativeMenuWillShowCount = 0 + private var nativeMenuDidShowCount = 0 + private var nativeCancelledCount = 0 + private var nativeAppCancelledCount = 0 + private var nativeReleaseProgress: CGFloat = 0 + private var nativeReleaseTranslation: CGFloat = 0 + private var nativeReleaseVelocity: CGFloat = 0 + private var nativeReleaseGestureState = -1 + private var nativeReleaseCancelled = false + private var nativeEndEditingResult = false + #endif + + /// UIKit keeps its recognizers, targets, delegates and interaction + /// driver. This simultaneous no-op pan is only a failure requirement: + /// it rejects an unowned contact or a non-edge content pan without + /// stealing the transcript's vertical or nested horizontal scrolling. + @MainActor private final class AdmissionDelegate: NSObject, UIGestureRecognizerDelegate { + private weak var owner: CompactMenuController? + + init(owner: CompactMenuController) { self.owner = owner } + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard let owner, gestureRecognizer === owner.admission else { return true } + let admitted = owner.admitsNativeContact() + #if DEBUG + owner.nativeAdmissionAttempts += 1 + if admitted { owner.nativeAdmissionAllowed += 1 } + owner.nativeEdgeEnabledAtAdmission = owner.configuredEdge?.isEnabled == true + owner.updateNativeDiagnostics() + #endif + return !admitted + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer + ) -> Bool { true } + } + + @MainActor private final class Operation { + let width: CGFloat + let interactive: Bool + let destinationMenu: Bool + var invalidated = false + var interactionEnded = false + var coordinatorInstalled = false + var visualProxy: CompactMenuVisualProxy? + + init(width: CGFloat, interactive: Bool, destinationMenu: Bool) { + self.width = width; self.interactive = interactive; self.destinationMenu = destinationMenu + } + } + + init( + scope: AccountScope, storageOwner: UUID?, lease: CompactMenuLease, + library: CompactMenuHostedContent, chat: CompactMenuHostedContent, + isCurrent: @escaping @MainActor () -> Bool, presentationBlocked: @escaping @MainActor () -> Bool, + requestMenu: @escaping @MainActor (Bool) -> Void, + blockPresentation: @escaping @MainActor (UUID, Bool) -> Void, + chatShown: @escaping @MainActor (CompactMenuChatReturn) -> Void + ) { + self.scope = scope; self.lease = lease + let libraryHost = UIHostingController(rootView: library) + let chatHost = UIHostingController(rootView: chat) + libraryHost.safeAreaRegions = .all + chatHost.safeAreaRegions = .all + self.libraryHost = libraryHost; self.chatHost = chatHost + libraryRoute = CompactMenuRouteController(hostingController: libraryHost) + chatRoute = CompactMenuRouteController(hostingController: chatHost, hidesAccessibility: true) + self.isCurrent = isCurrent; self.presentationBlocked = presentationBlocked + self.requestMenu = requestMenu; self.blockPresentation = blockPresentation; self.chatShown = chatShown + super.init(nibName: nil, bundle: nil) + lease.activate(scope: scope, storageOwner: storageOwner, controllerOwner: ownership) + delegate = self + libraryHost.rootView.publishBar = barPublisher(role: .library, storageOwner: storageOwner) + chatHost.rootView.publishBar = barPublisher(role: .chat, storageOwner: storageOwner) + libraryHost.rootView.presentationDismissed = presentationDismissalObserver() + chatHost.rootView.presentationDismissed = presentationDismissalObserver() + setViewControllers([libraryRoute, chatRoute], animated: false) + } + + required init?(coder: NSCoder) { return nil } + + override var isToolbarHidden: Bool { + get { super.isToolbarHidden } + set { super.isToolbarHidden = true } + } + + override func setToolbarHidden(_ hidden: Bool, animated: Bool) { + super.setToolbarHidden(true, animated: false) + } + + override func viewDidLoad() { + super.viewDidLoad() + let appearance = UINavigationBarAppearance() + appearance.configureWithTransparentBackground() + if let menuImage = UIImage(systemName: "line.3.horizontal") { + appearance.setBackIndicatorImage(menuImage, transitionMaskImage: menuImage) + } + navigationBar.standardAppearance = appearance + navigationBar.scrollEdgeAppearance = appearance + navigationBar.compactAppearance = appearance + navigationBar.compactScrollEdgeAppearance = appearance + navigationBar.tintColor = .label + libraryRoute.navigationItem.backButtonDisplayMode = .minimal + configureNativeBack(identifier: "chat.menu") + setNavigationBarHidden(false, animated: false) + setToolbarHidden(true, animated: false) + // The menu's physical-left interaction is independent of the + // explicitly localized layout direction of each hosting child. + view.semanticContentAttribute = .forceLeftToRight + admission.delegate = admissionDelegate + admission.cancelsTouchesInView = false + admission.addTarget(self, action: #selector(handleAdmission(_:))) + view.addGestureRecognizer(admission) + configureNativeFailureRequirements() + #if DEBUG + if exposesNativeDiagnostics { + nativeDiagnostics.frame = CGRect(x: 0, y: 0, width: 1, height: 1) + nativeDiagnostics.textColor = .clear + nativeDiagnostics.isUserInteractionEnabled = false + nativeDiagnostics.isAccessibilityElement = true + nativeDiagnostics.accessibilityIdentifier = "compact-menu.native-diagnostics" + view.addSubview(nativeDiagnostics) + updateNativeDiagnostics() + } + #endif + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + configureNativeFailureRequirements() + scheduleReconcile() + #if DEBUG + updateNativeDiagnostics() + #endif + } + + func update(requestedMenu: Bool, reduceMotion: Bool) { + guard !retired, current else { retire(); return } + self.requestedMenu = requestedMenu; self.reduceMotion = reduceMotion + synchronizeChatAccessibility() + if let operation, operation.interactive, !operation.interactionEnded, !requestedMenu { + operation.invalidated = true + cancelInteraction(operation) + } + scheduleReconcile() + } + + func retire() { + guard !retired else { return } + retired = true + chatRoute.setAccessibilityHidden(true) + clearRootBars() + navigationBar.accessibilityIdentifier = nil + navigationBar.accessibilityElementsHidden = true + navigationBar.isUserInteractionEnabled = false + cancelledChatWidth = nil + if let operation { + operation.invalidated = true + operation.visualProxy?.remove() + cancelInteraction(operation) + } + lease.retire(scope: scope, controllerOwner: ownership) + holdBlock(false) + } + + private func holdBlock(_ value: Bool) { + guard blockHeld != value else { return } + blockHeld = value; blockPresentation(presentationID, value) + } + + func barPublisher(role expected: CompactMenuBarRole, storageOwner: UUID?) -> CompactMenuBarPublicationHandler { + { [weak self] role, publisherOwner, publication in + guard let self, role == expected, !self.retired, self.current, + role == .library || self.lease.storageOwner == storageOwner + else { return } + self.publishRootBar(role: role, owner: publisherOwner, publication: publication) + } + } + + func presentationDismissalObserver() -> @MainActor @Sendable () -> Void { + { [weak self] in self?.presentationDismissed() } + } + + private func presentationDismissed() { + guard !retired, current else { return } + // SwiftUI's onDismiss can precede the end of an interactive sheet + // transition. UIKit still owns the presented controller until then. + if let coordinator = transitionCoordinator { + coordinator.animate(alongsideTransition: nil) { [weak self] _ in self?.scheduleReconcile() } + } else { + scheduleReconcile() + } + } + + private func publishRootBar(role: CompactMenuBarRole, owner: UUID, publication: CompactMenuBarPublication?) { + let state = role == .library ? libraryBar : chatBar + let route = role == .library ? libraryRoute : chatRoute + guard let publication else { + guard state.publisherOwner == owner else { return } + state.publisherOwner = nil + state.leading.clear(); state.principal.clear(); state.trailing.clear() + state.leadingItem = nil; state.trailingItem = nil + route.navigationItem.leftBarButtonItem = nil + route.navigationItem.titleView = nil + route.navigationItem.rightBarButtonItem = nil + // Preserve Settings' hidden state until the current Library + // root returns; withdrawal must never manufacture its return. + return + } + state.publisherOwner = owner + state.hidden = publication.hidden + route.navigationItem.title = publication.title + route.navigationItem.titleView = state.principal.update( + publication.principal, environment: publication.environment) + let trailing = state.trailing.update(publication.trailing, environment: publication.environment) + if let trailing { + if state.trailingItem == nil { state.trailingItem = UIBarButtonItem(customView: trailing) } + } else { + state.trailingItem = nil + } + route.navigationItem.rightBarButtonItem = state.trailingItem + if role == .library { + let leading = state.leading.update(publication.leading, environment: publication.environment) + if let leading { + if state.leadingItem == nil { state.leadingItem = UIBarButtonItem(customView: leading) } + } else { + state.leadingItem = nil + } + route.navigationItem.leftBarButtonItem = state.leadingItem + } else { + // UIKit's real Back item owns the Menu action and interaction. + // A replacement left item would disable standard pop admission. + route.navigationItem.leftBarButtonItem = nil + configureNativeBack(identifier: publication.backIdentifier) + } + if topViewController === route { applyBarVisibility(for: route) } + synchronizeBarAuthority() + } + + private func configureNativeBack(identifier: String) { + let label = MapleStrings.text("Menu") + if let back = libraryRoute.navigationItem.backBarButtonItem, + back.title == "", back.accessibilityIdentifier == identifier, back.accessibilityLabel == label + { + return + } + let back = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil) + back.accessibilityIdentifier = identifier + back.accessibilityLabel = label + libraryRoute.navigationItem.backBarButtonItem = back + } + + private func applyBarVisibility(for route: UIViewController) { + guard !retired, current else { return } + navigationBar.accessibilityIdentifier = route === chatRoute ? "chat.navigation" : "library.navigation" + let hidden = route === libraryRoute ? libraryBar.hidden : chatBar.hidden + if isNavigationBarHidden != hidden { setNavigationBarHidden(hidden, animated: false) } + } + + private func synchronizeBarAuthority() { + let authorized = + !retired && current && operation == nil && viewIfLoaded?.window != nil + && ((!requestedMenu && stableChat) || (requestedMenu && stableMenu)) && !ownedPresentation + navigationBar.isUserInteractionEnabled = authorized + navigationBar.accessibilityElementsHidden = !authorized + } + + private func clearRootBars() { + for (route, state) in [(libraryRoute, libraryBar), (chatRoute, chatBar)] { + route.navigationItem.leftBarButtonItem = nil + route.navigationItem.titleView = nil + route.navigationItem.rightBarButtonItem = nil + route.navigationItem.backBarButtonItem = nil + state.publisherOwner = nil + state.leading.clear(); state.principal.clear(); state.trailing.clear() + state.leadingItem = nil; state.trailingItem = nil + } + } + + private func configureNativeFailureRequirements() { + if let edge = interactivePopGestureRecognizer, configuredEdge !== edge { + edge.require(toFail: admission) + configuredEdge = edge + } + if #available(iOS 26.0, *), let content = interactiveContentPopGestureRecognizer, + configuredContent !== content + { + content.require(toFail: admission) + configuredContent = content + } + } + + #if DEBUG + private func updateNativeDiagnostics() { + guard exposesNativeDiagnostics else { return } + nativeDiagnostics.text = + "admission \(nativeAdmissionAttempts); allowed \(nativeAdmissionAllowed); edge-at-contact \(nativeEdgeEnabledAtAdmission ? 1 : 0); edge \(configuredEdge?.isEnabled == true ? 1 : 0):\(configuredEdge?.state.rawValue ?? -1); content \(configuredContent?.isEnabled == true ? 1 : 0):\(configuredContent?.state.rawValue ?? -1); will \(nativeWillShowCount); did \(nativeDidShowCount); menu \(nativeMenuWillShowCount):\(nativeMenuDidShowCount); cancelled \(nativeCancelledCount):\(nativeAppCancelledCount); top \(chatRoute.viewIfLoaded?.safeAreaInsets.top ?? -1):\(chatHost.viewIfLoaded?.safeAreaInsets.top ?? -1):\(viewIfLoaded?.window?.safeAreaInsets.top ?? -1); release \(nativeReleaseProgress):\(nativeReleaseTranslation):\(nativeReleaseVelocity):\(nativeReleaseGestureState):\(nativeReleaseCancelled ? 1 : 0); end-editing \(nativeEndEditingResult ? 1 : 0)" + } + #endif + + var acceptsUpdates: Bool { !retired && current } + private var current: Bool { isCurrent() && lease.isOwned(scope: scope, controllerOwner: ownership) } + private var width: CGFloat { viewIfLoaded?.bounds.width ?? 0 } + private var ownedPresentation: Bool { + presentedViewController != nil || libraryRoute.presentedViewController != nil + || chatRoute.presentedViewController != nil || libraryHost.presentedViewController != nil + || chatHost.presentedViewController != nil + } + private var stableChat: Bool { + topViewController === chatRoute && viewControllers.count == 2 + && viewControllers.first === libraryRoute && viewControllers.last === chatRoute + } + private var stableMenu: Bool { + topViewController === libraryRoute && viewControllers.count == 1 && viewControllers.first === libraryRoute + } + private var admitted: Bool { + !retired && !blockedContactActive && current && operation == nil && !requestedMenu && stableChat + && viewIfLoaded?.window != nil && width.isFinite && width > 0 + && !ownedPresentation && !presentationBlocked() + } + private var nativeContactActive: Bool { + [configuredEdge, configuredContent].contains { gesture in + gesture?.state == .began || gesture?.state == .changed + } + } + + private func admitsNativeContact() -> Bool { + guard admitted else { return false } + let location = admission.location(in: view), translation = admission.translation(in: view) + let originX = location.x - translation.x + // The iOS 26 content recognizer must not interpret a wide table or + // code swipe as outer navigation. UIKit still determines direction, + // progress, velocity, cancellation and completion for an edge pop. + return originX.isFinite && originX >= 0 && originX <= 44 + } + + @objc private func handleAdmission(_ gesture: UIPanGestureRecognizer) { + guard gesture === admission else { return } + switch gesture.state { + case .began: blockedContactActive = true + case .ended, .cancelled, .failed: + blockedContactActive = false; scheduleReconcile() + default: break + } + } + + private func synchronizeChatAccessibility() { + let window = viewIfLoaded?.window + let hidden = + retired || !current || !stableChat || requestedMenu + || window == nil || chatRoute.viewIfLoaded?.window !== window + || chatHost.viewIfLoaded?.window !== window + || lease.chatNavigationAccessibilityHidden(scope: scope, controllerOwner: ownership) + chatRoute.setAccessibilityHidden(hidden) + synchronizeBarAuthority() + } + + private func scheduleReconcile() { + guard !retired, !reconcilePending else { return } + reconcilePending = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.reconcilePending = false; self.reconcile() + } + } + + private func reconcile() { + guard !retired, current else { retire(); return } + synchronizeChatAccessibility() + guard viewIfLoaded?.window != nil, width.isFinite, width > 0 else { return } + if let operation { + bindNativeCoordinator(operation) + if abs(operation.width - width) > 0.5 { + operation.invalidated = true + requestedMenu = false; requestMenu(false) + cancelInteraction(operation) + } + return + } + guard !blockedContactActive, !nativeContactActive else { return } + if let cancelledChatWidth { + if abs(cancelledChatWidth - width) > 0.5 { + self.cancelledChatWidth = nil + needsFreshChatCompletion = true + freshChatReturnReason = .cancelledPreview + } else { + guard !ownedPresentation, settleCancelledChat(width: cancelledChatWidth) else { return } + self.cancelledChatWidth = nil + if !requestedMenu { + finishChat(reason: .cancelledPreview) + announceCancelledChatLayout(width: cancelledChatWidth) + } + } + } + // Only a cancelled ownership/size boundary temporarily disables a + // native recognizer. Restore it on a fresh owned route; ordinary + // admission uses failure requirements and leaves UIKit in control. + if stableChat, !requestedMenu { + cancelledEdge?.isEnabled = true; cancelledEdge = nil + cancelledContent?.isEnabled = true; cancelledContent = nil + } + guard !ownedPresentation else { return } + if requestedMenu, stableChat { + startNavigation(menu: true) + } else if !requestedMenu, stableMenu { + startNavigation(menu: false) + } else if needsFreshChatCompletion, stableChat, + lastShown === chatRoute || freshCompletionWithoutTransition + { + needsFreshChatCompletion = false; freshCompletionWithoutTransition = false + finishChat(reason: freshChatReturnReason) + } + } + + private func begin(_ value: Operation) { + operation = value + chatRoute.setAccessibilityHidden(true) + lease.begin(scope: scope, controllerOwner: ownership); holdBlock(true) + synchronizeBarAuthority() + } + + private func startNavigation(menu: Bool) { + guard operation == nil, current, width.isFinite, width > 0 else { return } + let value = Operation(width: width, interactive: false, destinationMenu: menu) + begin(value) + if menu { + if popViewController(animated: !reduceMotion) == nil { + operation = nil + requestedMenu = false; requestMenu(false) + finishChat(); scheduleReconcile() + } + } else { + pushViewController(chatRoute, animated: !reduceMotion) + } + } + + /// This delegate observes native navigation; it does not supply an + /// animation or interaction controller. UIKit owns the release motion. + func navigationController( + _ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool + ) { + guard navigationController === self, !retired, current else { return } + applyBarVisibility(for: viewController) + #if DEBUG + nativeWillShowCount += 1 + if viewController === libraryRoute { nativeMenuWillShowCount += 1 } + updateNativeDiagnostics() + #endif + if operation == nil, viewController === libraryRoute, + transitionCoordinator?.viewController(forKey: .from) === chatRoute || lastShown === chatRoute + { + let value = Operation( + width: width, + interactive: transitionCoordinator?.initiallyInteractive == true || nativeContactActive, + destinationMenu: true) + begin(value) + requestedMenu = true; requestMenu(true) + } + guard let operation else { return } + if viewController === libraryRoute, operation.destinationMenu, + !operation.invalidated, operation.visualProxy == nil, + let source = chatHost.viewIfLoaded, let container = chatRoute.viewIfLoaded + { + operation.visualProxy = CompactMenuVisualProxy( + source: source, container: container, + mayRestoreSource: { [weak self, weak operation] source in + guard let self, let operation, !self.retired, self.current, + self.operation === operation, self.chatHost.viewIfLoaded === source, + source.superview === self.chatRoute.viewIfLoaded + else { return false } + return true + }) + #if DEBUG + nativeEndEditingResult = source.endEditing(true) + updateNativeDiagnostics() + #else + _ = source.endEditing(true) + #endif + } + bindNativeCoordinator(operation) + } + + private func bindNativeCoordinator(_ captured: Operation) { + guard operation === captured, !captured.coordinatorInstalled, let coordinator = transitionCoordinator else { + return + } + captured.coordinatorInstalled = true + coordinator.notifyWhenInteractionChanges { [weak self, weak captured] context in + guard let self, let captured, self.operation === captured, !self.retired, self.current, + !context.isInteractive + else { return } + captured.interactionEnded = true + #if DEBUG + if self.exposesNativeDiagnostics { + let progress = context.percentComplete + self.nativeReleaseProgress = progress.isFinite ? progress : 0 + self.nativeReleaseCancelled = context.isCancelled + let pans = [self.configuredEdge, self.configuredContent].compactMap { + $0 as? UIPanGestureRecognizer + } + if let window = self.viewIfLoaded?.window, + let pan = pans.first(where: { $0.state != .possible && $0.state != .failed }) ?? pans.first + { + let translation = pan.translation(in: window).x, velocity = pan.velocity(in: window).x + self.nativeReleaseTranslation = translation.isFinite ? translation : 0 + self.nativeReleaseVelocity = velocity.isFinite ? velocity : 0 + self.nativeReleaseGestureState = pan.state.rawValue + } + self.updateNativeDiagnostics() + } + #endif + if context.isCancelled { + #if DEBUG + self.nativeCancelledCount += 1 + self.updateNativeDiagnostics() + #endif + self.requestedMenu = false; self.requestMenu(false) + } + } + coordinator.animate(alongsideTransition: nil) { [weak self, weak captured] _ in + guard let self, let captured, self.operation === captured else { return } + captured.visualProxy?.remove() + self.scheduleReconcile() + } + } + + private func cancelInteraction(_ captured: Operation) { + if captured.invalidated { captured.visualProxy?.remove() } + guard captured.interactive, !captured.interactionEnded else { return } + captured.interactionEnded = true + #if DEBUG + nativeAppCancelledCount += 1 + updateNativeDiagnostics() + #endif + // There is no public coordinator cancellation API. Cancelling only + // the active system recognizer terminates an obsolete contact; its + // delegate and targets remain untouched and UIKit owns the rollback. + if let edge = configuredEdge, edge.state == .began || edge.state == .changed { + cancelledEdge = edge; edge.isEnabled = false + } + if let content = configuredContent, content.state == .began || content.state == .changed { + cancelledContent = content; content.isEnabled = false + } + } + + func navigationController( + _ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool + ) { + guard navigationController === self, !retired, current else { return } + #if DEBUG + nativeDidShowCount += 1 + if viewController === libraryRoute { nativeMenuDidShowCount += 1 } + updateNativeDiagnostics() + #endif + lastShown = viewController + let finished = operation + finished?.visualProxy?.remove() + operation = nil + applyBarVisibility(for: viewController) + synchronizeBarAuthority() + let validWidth = finished.map { abs($0.width - width) <= 0.5 && !$0.invalidated } ?? true + if viewController === libraryRoute, stableMenu { + lease.showMenu(scope: scope, controllerOwner: ownership) + if !validWidth { requestedMenu = false; requestMenu(false) } + } else if viewController === chatRoute, stableChat { + if finished?.interactive == true { + requestedMenu = false; requestMenu(false) + } + if !requestedMenu { + if validWidth, let finished, finished.interactive, finished.destinationMenu { + // UIKit has restored the retained route. Complete its + // live child's layout before returning accessibility. + cancelledChatWidth = finished.width + } else if validWidth { + finishChat(reason: finished?.interactive == true ? .cancelledPreview : .navigation) + } else { + needsFreshChatCompletion = true + freshChatReturnReason = finished?.interactive == true ? .cancelledPreview : .navigation + } + } + } + scheduleReconcile() + } + + private func settleCancelledChat(width capturedWidth: CGFloat) -> Bool { + guard !retired, current, operation == nil, stableChat, lastShown === chatRoute, + let window = viewIfLoaded?.window, abs(capturedWidth - width) <= 0.5, + let chatView = chatRoute.viewIfLoaded, let hostedView = chatHost.viewIfLoaded, + chatView.window === window, hostedView.window === window, hostedView.superview === chatView, + abs(chatView.bounds.width - capturedWidth) <= 0.5 + else { return false } + UIView.performWithoutAnimation { + hostedView.setNeedsLayout(); hostedView.layoutIfNeeded() + } + return true + } + + private func finishChat(reason: CompactMenuChatReturn = .navigation) { + guard !retired, current, stableChat, !requestedMenu, viewIfLoaded?.window != nil, + width.isFinite, width > 0 + else { return } + needsFreshChatCompletion = false; freshCompletionWithoutTransition = false + freshChatReturnReason = .navigation + lease.showChat(scope: scope, controllerOwner: ownership) + synchronizeChatAccessibility() + holdBlock(false); chatShown(reason) + } + + private func announceCancelledChatLayout(width capturedWidth: CGFloat) { + guard let window = chatHost.viewIfLoaded?.window, let owner = lease.storageOwner else { return } + let revision = lease.state(scope: scope, storageOwner: owner).returnRevision + DispatchQueue.main.async { [weak self, weak window] in + guard let self, let window, !self.retired, self.current, + self.operation == nil, !self.blockedContactActive, !self.nativeContactActive, self.stableChat, + self.lastShown === self.chatRoute, !self.requestedMenu, + !self.lease.menuVisible, self.lease.storageOwner == owner, + self.viewIfLoaded?.window === window, self.chatHost.viewIfLoaded?.window === window, + abs(self.width - capturedWidth) <= 0.5, + !self.ownedPresentation, !self.presentationBlocked() + else { return } + let state = self.lease.state(scope: self.scope, storageOwner: owner) + guard !state.occluded, state.returnRevision == revision else { return } + UIAccessibility.post(notification: .layoutChanged, argument: nil) + } + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Navigation/ExternalLinkBrowser.swift b/apps/maple-ios/Sources/MapleAppUI/Navigation/ExternalLinkBrowser.swift new file mode 100644 index 000000000..a2caeaffc --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Navigation/ExternalLinkBrowser.swift @@ -0,0 +1,209 @@ +#if os(iOS) + import MapleAppCore + import Observation + import SwiftUI + import WebKit + + extension View { + func externalLinks( + _ requested: Binding, onPresentationChange: @escaping (Bool) -> Void = { _ in } + ) -> some View { + modifier(ExternalLinkPresentation(requested: requested, onPresentationChange: onPresentationChange)) + } + } + + private struct BrowserDestination: Identifiable { + let id = UUID() + let url: URL + } + + private struct ExternalLinkPresentation: ViewModifier { + @Binding var requested: URL? + let onPresentationChange: (Bool) -> Void + @State private var browser: BrowserDestination? + @Environment(\.openURL) private var openURL + + func body(content: Content) -> some View { + content + .alert( + MapleStrings.text("Open external link?"), + isPresented: Binding( + get: { requested != nil }, set: { if !$0 { requested = nil } } + ), presenting: requested + ) { url in + if ExternalLinkPolicy.isWeb(url) || ExternalLinkPolicy.isMail(url) { + Button(MapleStrings.text("Open link")) { + requested = nil + if ExternalLinkPolicy.isWeb(url) { + browser = BrowserDestination(url: url) + } else { + openURL(url) + } + }.accessibilityIdentifier("link.confirm") + } + Button(MapleStrings.text("Cancel"), role: .cancel) { requested = nil } + } message: { url in + if ExternalLinkPolicy.isWeb(url) || ExternalLinkPolicy.isMail(url) { + Text(MapleStrings.externalLinkMessage(url.absoluteString)) + } else { + Text(MapleStrings.text("This link type cannot be opened by Maple.")) + } + } + .sheet(item: $browser) { destination in LinkBrowser(url: destination.url) } + .onChange(of: requested != nil || browser != nil, initial: true) { _, presented in + onPresentationChange(presented) + } + .onDisappear { onPresentationChange(false) } + } + } + + /// The web view is only a user-requested external browser. It never receives + /// account tokens, conversation data, script messages, or an application bridge. + @MainActor @Observable private final class LinkBrowserModel: NSObject, WKNavigationDelegate, WKUIDelegate { + let webView: WKWebView + let initialURL: URL + var currentURL: URL + var canGoBack = false + var canGoForward = false + var isLoading = false + var failed = false + var externalLink: URL? + @ObservationIgnored private var observations: [NSKeyValueObservation] = [] + + init(url: URL) { + initialURL = url; currentURL = url + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .nonPersistent() + configuration.preferences.javaScriptCanOpenWindowsAutomatically = false + webView = WKWebView(frame: .zero, configuration: configuration) + super.init() + webView.navigationDelegate = self; webView.uiDelegate = self + webView.allowsBackForwardNavigationGestures = true + observations = [ + webView.observe(\.url, options: [.new]) { [weak self] _, _ in + Task { @MainActor [weak self] in self?.refresh() } + }, + webView.observe(\.isLoading, options: [.new]) { [weak self] _, _ in + Task { @MainActor [weak self] in self?.refresh() } + }, + webView.observe(\.canGoBack, options: [.new]) { [weak self] _, _ in + Task { @MainActor [weak self] in self?.refresh() } + }, + webView.observe(\.canGoForward, options: [.new]) { [weak self] _, _ in + Task { @MainActor [weak self] in self?.refresh() } + }, + ] + if ExternalLinkPolicy.isWeb(url) { webView.load(URLRequest(url: url)) } + } + + func close() { + webView.stopLoading(); observations.removeAll() + webView.navigationDelegate = nil; webView.uiDelegate = nil + } + + private func refresh() { + if let url = webView.url, ExternalLinkPolicy.isWeb(url) { currentURL = url } + isLoading = webView.isLoading + canGoBack = webView.canGoBack; canGoForward = webView.canGoForward + } + + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async + -> WKNavigationActionPolicy + { + guard let url = navigationAction.request.url else { return .cancel } + if ExternalLinkPolicy.isWeb(url) { return .allow } + if navigationAction.navigationType == .linkActivated, ExternalLinkPolicy.isMail(url) { + externalLink = url + } + return .cancel + } + + func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse) async + -> WKNavigationResponsePolicy + { + guard let url = navigationResponse.response.url, ExternalLinkPolicy.isWeb(url), + navigationResponse.canShowMIMEType + else { failed = true; return .cancel } + return .allow + } + + func webView( + _ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures + ) -> WKWebView? { + if navigationAction.targetFrame == nil, let url = navigationAction.request.url, + ExternalLinkPolicy.isWeb(url) + { + webView.load(URLRequest(url: url)) + } + return nil + } + + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { failed = false } + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { refresh() } + func webView( + _ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error + ) { + if (error as NSError).code != NSURLErrorCancelled { failed = true } + } + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + if (error as NSError).code != NSURLErrorCancelled { failed = true } + } + func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { failed = true } + } + + private struct ExternalWebContent: UIViewRepresentable { + let model: LinkBrowserModel + func makeUIView(context: Context) -> WKWebView { model.webView } + func updateUIView(_ uiView: WKWebView, context: Context) {} + } + + private struct LinkBrowser: View { + @State private var model: LinkBrowserModel + @Environment(\.dismiss) private var dismiss + @Environment(\.openURL) private var openURL + init(url: URL) { _model = State(initialValue: LinkBrowserModel(url: url)) } + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + if model.failed { + HStack { + Text(MapleStrings.text("This page couldn’t be loaded.")) + Button(MapleStrings.text("Try again")) { + model.webView.load(URLRequest(url: model.currentURL)) + } + }.font(.footnote).padding() + } + ExternalWebContent(model: model) + } + .navigationTitle(model.currentURL.host() ?? "") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(MapleStrings.text("Done")) { dismiss() }.accessibilityIdentifier("browser.close") + } + ToolbarItem(placement: .primaryAction) { + Button(MapleStrings.text("Open in default browser"), systemImage: "arrow.up.right.square") { + guard ExternalLinkPolicy.isWeb(model.currentURL) else { return } + openURL(model.currentURL) + }.accessibilityIdentifier("browser.external") + } + ToolbarItemGroup(placement: .bottomBar) { + Button(MapleStrings.text("Back"), systemImage: "chevron.backward") { model.webView.goBack() } + .disabled( + !model.canGoBack) + Button(MapleStrings.text("Forward"), systemImage: "chevron.forward") { + model.webView.goForward() + }.disabled( + !model.canGoForward) + Spacer() + Button(MapleStrings.text("Reload"), systemImage: "arrow.clockwise") { model.webView.reload() } + } + } + .externalLinks($model.externalLink) + } + .onDisappear { model.close() } + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Navigation/LibraryContext.swift b/apps/maple-ios/Sources/MapleAppUI/Navigation/LibraryContext.swift new file mode 100644 index 000000000..72fd9a17c --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Navigation/LibraryContext.swift @@ -0,0 +1,122 @@ +#if os(iOS) + import MapleAppCore + import MapleSDK + + @MainActor private final class WeakLibraryOwner { + weak var value: Value? + init(_ value: Value) { self.value = value } + } + + /// Independent page cursors share one account and deletion authority. The + /// registry retains no invisible page contents after its view is released. + @MainActor final class LibraryContext { + let root: LibraryModel + let scope: AccountScope + let projectExpansion: LibraryProjectExpansionController + let menu: LibraryMenuModel + private let service: SDKLibraryService + private let deletions: ChatDeletionCoordinator + private var active = true + private var models: [WeakLibraryOwner] = [] + private var editors: [WeakLibraryOwner] = [] + var onConversationsDeleted: ((Set, Bool) -> Void)? + var onConversationUpdated: ((Conversation) -> Void)? + var onConversationProjectUpdated: ((Set, String?) -> Void)? + var onProjectDeleted: ((String) -> Void)? + + init( + scope: AccountScope, api: ResearchAPI, deletions: ChatDeletionCoordinator, + projectExpansion: LibraryProjectExpansionController + ) { + let service = SDKLibraryService(api: api) + self.scope = scope; self.service = service; self.deletions = deletions + self.projectExpansion = projectExpansion + let root = LibraryModel(scope: scope, service: service, deletions: deletions) + self.root = root + menu = LibraryMenuModel(root: root) { + LibraryModel(scope: scope, service: service, deletions: deletions) + } + attach(root); attach(menu.pinned); attach(menu.recent) + menu.onModelCreated = { [weak self] model in self?.attach(model) } + } + + func prepareMenu() async { + await menu.prepare { [weak projectExpansion] in projectExpansion?.isExpanded(id: $0) ?? false } + } + + func refreshMenu() async { + await menu.refresh { [weak projectExpansion] in projectExpansion?.isExpanded(id: $0) ?? false } + } + + func receiveConversationDetails(_ conversation: Conversation) { + guard active else { return } + for model in models.compactMap(\.value) { + model.receiveConversationUpdate(conversation, from: scope) + } + } + + func makeLibrary() -> LibraryModel { + let model = LibraryModel(scope: scope, service: service, deletions: deletions) + attach(model) + if !active { model.invalidate() } + return model + } + + func makeInstructionEditor(_ destination: InstructionDestination) -> InstructionEditor { + let editor = InstructionEditor(scope: scope, destination: destination, service: service) + editors.removeAll { $0.value == nil }; editors.append(WeakLibraryOwner(editor)) + if !active { editor.invalidate() } + return editor + } + + func invalidate() { + active = false + menu.invalidate() + projectExpansion.invalidate() + for model in models { model.value?.invalidate() } + for editor in editors { editor.value?.invalidate() } + models.removeAll(); editors.removeAll() + onConversationsDeleted = nil; onConversationUpdated = nil; onConversationProjectUpdated = nil + onProjectDeleted = nil + } + + private func attach(_ model: LibraryModel) { + models.removeAll { $0.value == nil }; models.append(WeakLibraryOwner(model)) + model.onConversationsDeleted = { [weak self, weak model] ids, all in + guard let self, active else { return } + for other in models.compactMap(\.value) where other !== model { + other.receiveDeletion(ids, all: all, from: scope) + } + onConversationsDeleted?(ids, all) + } + model.onConversationUpdated = { [weak self, weak model] value in + guard let self, active else { return } + for other in models.compactMap(\.value) where other !== model { + other.receiveConversationUpdate(value, from: scope) + } + onConversationUpdated?(value) + } + model.onConversationProjectUpdated = { [weak self, weak model] ids, projectID in + guard let self, active else { return } + for other in models.compactMap(\.value) where other !== model { + other.receiveProjectMembership(ids, projectID: projectID, from: scope) + } + onConversationProjectUpdated?(ids, projectID) + } + model.onProjectDeleted = { [weak self, weak model] id in + guard let self, active else { return } + projectExpansion.setExpanded(id: id, expanded: false) + for other in models.compactMap(\.value) where other !== model { + other.receiveProjectDeletion(id, from: scope) + } + onProjectDeleted?(id) + } + model.onProjectUpdated = { [weak self, weak model] value in + guard let self, active else { return } + for other in models.compactMap(\.value) where other !== model { + other.receiveProjectUpdate(value, from: scope) + } + } + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Navigation/MapleLaunchTransition.swift b/apps/maple-ios/Sources/MapleAppUI/Navigation/MapleLaunchTransition.swift new file mode 100644 index 000000000..85af30603 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Navigation/MapleLaunchTransition.swift @@ -0,0 +1,188 @@ +#if os(iOS) + import Observation + import SwiftUI + import UIKit + + /// A scene-local visual handoff. It never delays account restoration or + /// guesses the toolbar's position from a device size or safe-area constant. + @MainActor @Observable final class MapleLaunchTransition { + var finished = false + var departing = false + private(set) var destination: CGRect? + private var ready = false + private var reduceMotion = false + + func accountReady(_ ready: Bool, reduceMotion: Bool) { + self.ready = ready + self.reduceMotion = reduceMotion + beginIfReady() + } + + func locateLock(_ frame: CGRect) { + guard !finished, !departing, !frame.isNull, !frame.isInfinite, + frame.width > 0, frame.height > 0 + else { return } + destination = frame + beginIfReady() + } + + func finishWithoutChat() { + guard !finished else { return } + finished = true + } + + private func beginIfReady() { + guard ready, destination != nil, !finished, !departing else { return } + withAnimation(reduceMotion ? .easeOut(duration: 0.15) : .spring(duration: 0.38, bounce: 0.08)) { + departing = true + } completion: { + self.finished = true + } + } + } + + private struct MapleLaunchTransitionKey: EnvironmentKey { + static let defaultValue: MapleLaunchTransition? = nil + } + + extension EnvironmentValues { + var mapleLaunchTransition: MapleLaunchTransition? { + get { self[MapleLaunchTransitionKey.self] } + set { self[MapleLaunchTransitionKey.self] = newValue } + } + } + + struct MaplePrivacyLock: View { + @Environment(\.mapleLaunchTransition) private var launch + + var body: some View { + Image(systemName: "lock.fill") + .font(.caption2) + .foregroundStyle(.secondary) + .opacity(launch?.finished == false ? 0 : 1) + .background(LaunchLockAnchor(launch: launch)) + .accessibilityLabel(MapleStrings.text("Private conversation")) + .accessibilityIdentifier("chat.privacy") + } + } + + /// The chat is hosted by a UINavigationController on compact screens; + /// measure the real toolbar in its window rather than crossing SwiftUI + /// hosting boundaries with a matched-geometry preference. + private struct LaunchLockAnchor: UIViewRepresentable { + let launch: MapleLaunchTransition? + + func makeUIView(context: Context) -> Anchor { Anchor() } + func updateUIView(_ view: Anchor, context: Context) { + view.launch = launch + view.reportAfterLayout() + } + + final class Anchor: UIView { + weak var launch: MapleLaunchTransition? + + override func didMoveToWindow() { + super.didMoveToWindow() + reportAfterLayout() + } + + override func layoutSubviews() { + super.layoutSubviews() + reportAfterLayout() + } + + func reportAfterLayout() { + guard launch?.finished == false else { return } + DispatchQueue.main.async { [weak self] in + guard let self, let window = self.window else { return } + self.launch?.locateLock(self.convert(self.bounds, to: window)) + } + } + } + } + + struct MapleLaunchSurface: View { + var body: some View { + GeometryReader { geometry in + ZStack { + MapleSurface.canvas + MapleWordmark().frame(width: 130, height: 28) + .position(x: geometry.size.width / 2, y: geometry.size.height / 2) + .accessibilityLabel(Text(verbatim: "Maple")) + .accessibilityIdentifier("startup.logo") + Image(systemName: "lock.fill").font(.caption2).foregroundStyle(.secondary) + .position(x: geometry.size.width / 2, y: geometry.size.height / 2 + 38) + .accessibilityLabel(MapleStrings.text("Private conversation")) + .accessibilityIdentifier("startup.lock") + } + } + .ignoresSafeArea() + .accessibilityElement(children: .contain) + .accessibilityLabel(Text(verbatim: "Maple")) + .accessibilityIdentifier("startup.wordmark") + } + } + + struct MapleLaunchOverlay: View { + let launch: MapleLaunchTransition + + var body: some View { + GeometryReader { geometry in + let origin = geometry.frame(in: .global).origin + let destination = launch.destination + ZStack { + MapleSurface.canvas.opacity(launch.departing ? 0 : 1) + MapleWordmark().frame(width: 130, height: 28) + .position(x: geometry.size.width / 2, y: geometry.size.height / 2) + .opacity(launch.departing ? 0 : 1) + Image(systemName: "lock.fill").font(.caption2).foregroundStyle(.secondary) + .position( + x: launch.departing ? (destination?.midX ?? origin.x) - origin.x : geometry.size.width / 2, + y: launch.departing + ? (destination?.midY ?? origin.y) - origin.y : geometry.size.height / 2 + 38) + } + } + .ignoresSafeArea() + .allowsHitTesting(false) + .accessibilityHidden(true) + } + } + + #if DEBUG + /// Pauses only a synthetic launch so XCTest can observe the same + /// full-scene surface and native toolbar handoff without network timing. + struct MapleLaunchTransitionFixture: View { + @State private var launch = MapleLaunchTransition() + @State private var ready = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + Group { + if ready { + NavigationStack { + MapleSurface.canvas.ignoresSafeArea() + .toolbar { + ToolbarItem(placement: .principal) { MaplePrivacyLock() } + } + } + } else { + MapleLaunchSurface() + } + } + .environment(\.mapleLaunchTransition, launch) + .overlay { + if !launch.finished { MapleLaunchOverlay(launch: launch) } + } + .overlay(alignment: .bottom) { + if !ready { + Button("Continue") { ready = true } + .accessibilityIdentifier("startup.fixture.continue") + } else if launch.finished { + Text("Complete").accessibilityIdentifier("startup.fixture.complete") + } + } + .onChange(of: ready) { _, value in launch.accountReady(value, reduceMotion: reduceMotion) } + } + } + #endif +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Navigation/SheetOutsideTapDismissal.swift b/apps/maple-ios/Sources/MapleAppUI/Navigation/SheetOutsideTapDismissal.swift new file mode 100644 index 000000000..6f536a525 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Navigation/SheetOutsideTapDismissal.swift @@ -0,0 +1,116 @@ +#if os(iOS) + import SwiftUI + import UIKit + + /// Adds outside-tap dismissal to a native sheet without enabling the controls + /// behind it. UIKit still owns the sheet's dimming, sizing and drag gestures. + struct SheetOutsideTapDismissal: UIViewControllerRepresentable { + let onDismiss: () -> Void + + func makeUIViewController(context: Context) -> OutsideTapController { + OutsideTapController(onDismiss: onDismiss) + } + + func updateUIViewController(_ controller: OutsideTapController, context: Context) { + controller.onDismiss = onDismiss + controller.installRecognizer() + } + + static func dismantleUIViewController(_ controller: OutsideTapController, coordinator: ()) { + controller.removeRecognizer() + } + + final class OutsideTapController: UIViewController, UIGestureRecognizerDelegate { + var onDismiss: () -> Void + private weak var observedPresentation: UIPresentationController? + private var outsideTap: UITapGestureRecognizer? + + init(onDismiss: @escaping () -> Void) { + self.onDismiss = onDismiss + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is unavailable") } + + override func loadView() { + let observer = UIView() + observer.backgroundColor = .clear + observer.isUserInteractionEnabled = false + view = observer + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + installRecognizer() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + installRecognizer() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + removeRecognizer() + } + + func installRecognizer() { + guard viewIfLoaded?.window != nil else { return } + var candidate: UIViewController? = self + while let controller = candidate { + if controller.presentingViewController != nil, + let presentation = controller.presentationController, + let container = presentation.containerView + { + if observedPresentation === presentation, outsideTap?.view === container { return } + removeRecognizer() + let tap = UITapGestureRecognizer(target: self, action: #selector(dismissFromOutside(_:))) + tap.delegate = self + tap.cancelsTouchesInView = false + container.addGestureRecognizer(tap) + observedPresentation = presentation + outsideTap = tap + return + } + candidate = controller.parent + } + } + + func removeRecognizer() { + if let outsideTap { outsideTap.view?.removeGestureRecognizer(outsideTap) } + outsideTap = nil + observedPresentation = nil + } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + guard let presentation = observedPresentation, + let container = presentation.containerView, let presented = presentation.presentedView, + presentation.presentedViewController.presentedViewController == nil, + !presentation.presentedViewController.isBeingDismissed + else { return false } + // Native sheet controls can extend beyond the content bounds. + // Their view ownership still identifies them as sheet taps. + if touch.view?.isDescendant(of: presented) == true { return false } + let point = presented.convert(touch.location(in: container), from: container) + return !presented.bounds.contains(point) + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + true + } + + @objc private func dismissFromOutside(_ gesture: UITapGestureRecognizer) { + guard gesture.state == .ended, + let presentation = observedPresentation, + presentation.presentedViewController.presentedViewController == nil, + !presentation.presentedViewController.isBeingDismissed + else { return } + onDismiss() + } + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Rendering/EquationDiagnosticView.swift b/apps/maple-ios/Sources/MapleAppUI/Rendering/EquationDiagnosticView.swift new file mode 100644 index 000000000..9e18e262b --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Rendering/EquationDiagnosticView.swift @@ -0,0 +1,279 @@ +#if os(iOS) && DEBUG + import Foundation + import Network + import SwiftUI + import UIKit + + struct EquationDiagnosticView: View { + @State private var status = "Not run" + @State private var images: [Data] = [] + @State private var running = false + @State private var windowHost = EquationLifecycleWindowHost(frame: .zero) + var body: some View { + VStack(alignment: .leading) { + Button("Run equation checks") { running = true } + .accessibilityIdentifier("renderer.equation-checks").disabled(running) + Text(status).accessibilityIdentifier("renderer.equation-status") + ForEach(images.indices, id: \.self) { index in + if let image = UIImage(data: images[index]) { + Image(uiImage: image).resizable().scaledToFit().frame(maxHeight: 70) + .accessibilityLabel("Equation diagnostic \(index + 1)") + } + } + } + .background { + EquationLifecycleWindowAnchor(host: windowHost) + .frame(width: 0, height: 0) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + .task(id: running) { + guard running else { return } + defer { running = false } + let canary = EquationLoopbackCanary() + defer { canary.stop() } + do { + status = "Starting control" + let endpoint = try await canary.start() + try canary.startControl(endpoint: endpoint) + for _ in 0..<30 + where !canary.controlCompleted + || canary.connections == 0 || canary.receivedConnections == 0 + { + try await Task.sleep(for: .milliseconds(10)) + } + guard canary.controlCompleted, canary.connections == 1, + canary.receivedConnections == 1, canary.requestKinds == 1 + else { + status = + "Control failed; connections \(canary.connections)" + + "; received \(canary.receivedConnections); kinds \(canary.requestKinds)" + + "; control closed \(canary.controlCompleted ? 1 : 0)" + return + } + canary.resetProbeObservations() + status = "Checking containment" + let policy = EquationRenderer(host: windowHost) + let completed = try await policy.exerciseContainmentForTesting(endpoint: endpoint) + try await Task.sleep(for: .milliseconds(500)) + guard completed, canary.connections == 0 else { + status = + "Containment failed; completed \(completed ? 1 : 0)" + + "; connections \(canary.connections)" + + "; received \(canary.receivedConnections); kinds \(canary.requestKinds)" + return + } + status = "Checking equation pixels" + let source = Self.formulas.enumerated().map { index, latex in + MessageFormula( + id: String(index), latex: latex, original: latex, display: true, isWithinBudget: true) + } + let rendered = try await EquationRenderer(host: windowHost).render(source) + images = source.compactMap { rendered[$0.id]?.png } + let count = images.filter(Self.hasVisibleGlyphs).count + status = "Equations \(count)/6; network blocked" + } catch { status = Task.isCancelled ? "Cancelled" : status + " failed" } + } + } + + static let formulas = [ + #"\frac{1}{2}+\sqrt{x^2+1}"#, #"\operatorname{argmax}_{x} f(x)"#, + #"\Bigl(\frac{a}{b}\Bigr)"#, #"\boxed{E=mc^2}"#, + #"\newcommand{\RR}{\mathbb{R}}\RR^2"#, #"E=mc^2\tag{1}"#, + ] + + static func hasVisibleGlyphs(_ data: Data) -> Bool { + guard let image = UIImage(data: data)?.cgImage else { return false } + let width = image.width, height = image.height + guard width > 0, height > 0, width * height <= 4_194_304 else { return false } + var pixels = [UInt8](repeating: 0, count: width * height * 4) + let drawn = pixels.withUnsafeMutableBytes { buffer -> Bool in + guard + let context = CGContext( + data: buffer.baseAddress, width: width, height: height, + bitsPerComponent: 8, bytesPerRow: width * 4, space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) + else { return false } + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + return true + } + guard drawn else { return false } + var ink = 0 + var touchesEdge = false + for offset in stride(from: 0, to: pixels.count, by: 4) { + if pixels[offset + 3] > 32, pixels[offset] < 128, pixels[offset + 1] < 128, pixels[offset + 2] < 128 { + ink += 1 + let index = offset / 4 + let x = index % width, y = index / width + if x == 0 || y == 0 || x == width - 1 || y == height - 1 { touchesEdge = true } + } + } + // The renderer includes padding. Ink at the raster edge indicates + // stale font metrics or clipped output, not a complete equation. + return ink > 5 && ink < width * height * 3 / 4 && !touchesEdge + } + } + + @MainActor + private final class EquationLoopbackCanary { + enum Failure: Error { case unavailable, timeout, stopped } + private var listener: NWListener? + private var ready: CheckedContinuation? + private var timer: Task? + private let queue = DispatchQueue(label: "maple.synthetic-equation-canary") + var connections = 0 + private(set) var receivedConnections = 0 + private(set) var requestKinds: UInt32 = 0 + private(set) var controlCompleted = false + private var controlConnection: NWConnection? + private var controlStarted = false + private var controlResponseValid = false + nonisolated private static let response = Data( + "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nOK".utf8) + + // One DEBUG positive-control connection, without a session pool or retry. + // Its valid response and final cancellation must precede the probe reset. + func startControl(endpoint: URL) throws { + guard controlConnection == nil, endpoint.scheme == "http", endpoint.host == "127.0.0.1", + let port = endpoint.port, (1...65535).contains(port), + endpoint.user == nil, endpoint.password == nil, endpoint.query == nil, endpoint.fragment == nil, + endpoint.path.isEmpty || endpoint.path == "/", + let networkPort = NWEndpoint.Port(rawValue: UInt16(port)) + else { throw Failure.unavailable } + let connection = NWConnection(host: .ipv4(.loopback), port: networkPort, using: .tcp) + controlConnection = connection + connection.stateUpdateHandler = { [weak self] state in + switch state { + case .ready: + Task { @MainActor [weak self] in + guard let self, self.controlConnection === connection, !self.controlStarted else { return } + self.controlStarted = true + let request = Data( + "GET /control HTTP/1.1\r\nHost: 127.0.0.1:\(port)\r\nConnection: close\r\n\r\n".utf8) + connection.send( + content: request, + completion: .contentProcessed { error in + guard error == nil else { connection.cancel(); return } + connection.receive( + minimumIncompleteLength: Self.response.count, maximumLength: 4096 + ) { [weak self] data, _, _, error in + let valid = error == nil && data == Self.response + Task { @MainActor [weak self] in + guard let self, self.controlConnection === connection else { + connection.cancel(); return + } + self.controlResponseValid = valid + connection.cancel() + } + } + }) + } + case .cancelled: + connection.stateUpdateHandler = nil + Task { @MainActor [weak self] in + guard let self, self.controlConnection === connection else { return } + self.controlCompleted = self.controlResponseValid + self.controlConnection = nil + } + case .failed: connection.cancel() + default: break + } + } + connection.start(queue: queue) + } + + func resetProbeObservations() { + connections = 0 + receivedConnections = 0 + requestKinds = 0 + } + + // DEBUG canary only: fixed path categories, never captured bytes or URLs. + // Bits0...15: control/fetch/https/xhr/websocket/image/relative-image/ + // stylesheet/import/font/frame/beacon/form/popup/download/navigation. + // Bits16...18: TLS record/no payload/unrecognized. A still-pending + // receive increments neither receivedConnections nor requestKinds. + nonisolated private static func requestKind(_ data: Data?) -> UInt32 { + guard let data, !data.isEmpty else { return 1 << 17 } + guard data.count <= 4096 else { return 1 << 18 } + let prefix = data.prefix(3) + if prefix.first == 0x16, prefix.dropFirst().first == 0x03 { return 1 << 16 } + guard let request = String(data: data, encoding: .utf8)?.components(separatedBy: "\r\n").first + else { return 1 << 18 } + let fields = request.split(separator: " ", omittingEmptySubsequences: false) + guard fields.count == 3, fields[0] == "GET" || fields[0] == "POST", + fields[2] == "HTTP/1.1" || fields[2] == "HTTP/1.0" + else { return 1 << 18 } + switch fields[1] { + case "/control": return 1 + case "/fetch": return 1 << 1 + case "/https": return 1 << 2 + case "/xhr": return 1 << 3 + case "/websocket": return 1 << 4 + case "/image": return 1 << 5 + case "/relative-image": return 1 << 6 + case "/stylesheet": return 1 << 7 + case "/import": return 1 << 8 + case "/font": return 1 << 9 + case "/frame": return 1 << 10 + case "/beacon": return 1 << 11 + case "/form": return 1 << 12 + case "/popup": return 1 << 13 + case "/download": return 1 << 14 + case "/navigation": return 1 << 15 + default: return 1 << 18 + } + } + + func start() async throws -> URL { + let parameters = NWParameters.tcp + parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: .any) + let listener = try NWListener(using: parameters) + self.listener = listener + listener.newConnectionHandler = { [weak self, queue] connection in + Task { @MainActor in self?.connections += 1 } + connection.start(queue: queue) + connection.receive(minimumIncompleteLength: 1, maximumLength: 4096) { [weak self] data, _, _, _ in + let kind = Self.requestKind(data) + Task { @MainActor in + guard let self else { return } + self.receivedConnections = min(32, self.receivedConnections + 1) + self.requestKinds |= kind + } + connection.send( + content: Self.response, + completion: .contentProcessed { _ in connection.cancel() }) + } + } + listener.stateUpdateHandler = { [weak self, weak listener] state in + Task { @MainActor in + guard let self else { return } + if case .ready = state, let port = listener?.port { + let endpoint = URL(string: "http://127.0.0.1:\(port.rawValue)")! + self.ready?.resume(returning: endpoint); self.ready = nil; self.timer?.cancel() + } else if case .failed = state { + self.stop() + } + } + } + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + ready = continuation + timer = Task { @MainActor [weak self] in + do { try await Task.sleep(for: .seconds(3)) } catch { return } + self?.stop() + } + listener.start(queue: queue) + } + } onCancel: { + Task { @MainActor [weak self] in self?.stop() } + } + } + func stop() { + controlConnection?.cancel(); controlConnection = nil + timer?.cancel(); timer = nil + listener?.cancel(); listener = nil + ready?.resume(throwing: Failure.stopped); ready = nil + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Rendering/EquationRenderer.swift b/apps/maple-ios/Sources/MapleAppUI/Rendering/EquationRenderer.swift new file mode 100644 index 000000000..70cc14c0e --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Rendering/EquationRenderer.swift @@ -0,0 +1,680 @@ +#if os(iOS) + import Foundation + #if DEBUG + import Observation + #endif + import SwiftUI + import Textual + import UIKit + import WebKit + + /// Equation snapshots are in memory only and owned by one message revision. + /// The WebKit process never receives a transcript, account, URL, or credential. + @MainActor + final class EquationRenderer { + enum Failure: Error { case rejected, unavailable, timedOut, cancelled } + private var webView: WKWebView? + private let assets = EquationAssets() + private let navigation = EquationNavigation() + private var stopped = false + private weak var host: EquationLifecycleWindowHost? + private weak var attachedWindow: UIWindow? + private var container: UIView? + private var pendingMount: (() -> Void)? + + init(host: EquationLifecycleWindowHost) { self.host = host } + + #if DEBUG + private var diagnostic: EquationPhaseDiagnostic? + /// Only the fixed synthetic lifecycle fixture supplies this observer. + init( + diagnostic: EquationPhaseDiagnostic, host: EquationLifecycleWindowHost + ) { + self.diagnostic = diagnostic + self.host = host + assets.diagnostic = diagnostic + navigation.diagnostic = diagnostic + } + #endif + + func render(_ formulas: [MessageFormula]) async throws -> [String: EquationAttachment] { + guard !formulas.isEmpty else { return [:] } + let permit = try await EquationWorkAdmission.shared.acquire() + defer { invalidate(); EquationWorkAdmission.shared.release(permit) } + let web = try await startSession() + var results: [String: EquationAttachment] = [:] + var totalPixels = 0 + let deadline = ContinuousClock.now.advanced(by: .seconds(8)) + for formula in formulas where formula.isWithinBudget { + try Task.checkCancellation() + guard !stopped, ContinuousClock.now < deadline else { break } + #if DEBUG + diagnostic?.begin(.metric) + #endif + let metric: EquationMetric? = try await bounded { finish in + web.callAsyncJavaScript( + "return await window.mapleEquation(source, display)", + arguments: ["source": formula.latex, "display": formula.display], + in: nil, in: .page + ) { result in + switch result { + case .success(let value): finish(.success(EquationMetric(value))) + case .failure: finish(.success(nil)) + } + } + } + try Task.checkCancellation() + guard let metric else { continue } + totalPixels += Int(metric.width * metric.height * 4) + guard totalPixels <= 4_194_304 else { break } + // Reserve aggregate decoded cost before materializing an image. + guard let lease = EquationRasterBudget.shared.reserve(bytes: Int(metric.width * metric.height * 16)) + else { break } + web.frame.size = CGSize(width: metric.width, height: metric.height) + let options = WKSnapshotConfiguration() + options.rect = CGRect(x: 0, y: 0, width: metric.width, height: metric.height) + options.snapshotWidth = NSNumber(value: metric.width * 2) + options.afterScreenUpdates = false + #if DEBUG + diagnostic?.begin(.snapshot) + #endif + let png = try await snapshot(web, options: options, lease: lease) + try Task.checkCancellation() + guard !stopped else { throw Failure.cancelled } + results[formula.id] = EquationAttachment( + source: formula.original, png: png, lease: lease, + width: metric.width, height: metric.height, descent: metric.descent) + } + return results + } + + private func snapshot( + _ web: WKWebView, options: WKSnapshotConfiguration, lease: EquationRasterLease + ) async throws -> Data { + try Task.checkCancellation() + guard !stopped, webView === web, let ownerHost = host, + let ownerWindow = attachedWindow, let ownerContainer = container, + ownerHost.renderer === self, ownerHost.window === ownerWindow, + ownerContainer.superview === ownerHost, ownerContainer.window === ownerWindow, + web.superview === ownerContainer, web.window === ownerWindow, + ownerWindow.windowScene?.activationState == .foregroundActive + else { throw Failure.cancelled } + + // Capture the page without the offscreen window's render-server surface. + // Only this renderer's web view is detached; its lifecycle host stays owned. + web.removeFromSuperview() + let ownsCapture: @MainActor @Sendable () -> Bool = { [weak self] in + guard let self else { return false } + return !stopped && webView === web && host === ownerHost + && attachedWindow === ownerWindow && container === ownerContainer + && ownerHost.renderer === self && ownerHost.window === ownerWindow + && ownerContainer.superview === ownerHost && ownerContainer.window === ownerWindow + && ownerWindow.windowScene?.activationState == .foregroundActive + && web.superview == nil && web.window == nil + } + guard ownsCapture() else { throw Failure.cancelled } + let png: Data = try await bounded { finish in + web.takeSnapshot(with: options) { image, error in + // A cancelled capture keeps its raster reservation until WebKit + // releases this callback, and cannot restore or publish late pixels. + withExtendedLifetime(lease) { + guard ownsCapture() else { finish(.failure(Failure.cancelled)); return } + guard error == nil, let png = image?.pngData(), png.count <= 4_194_304 else { + finish(.failure(Failure.unavailable)); return + } + finish(.success(png)) + } + } + } + try Task.checkCancellation() + guard ownsCapture() else { throw Failure.cancelled } + ownerContainer.addSubview(web) + guard web.superview === ownerContainer, web.window === ownerWindow else { + web.removeFromSuperview() + throw Failure.cancelled + } + return png + } + + private func startSession() async throws -> WKWebView { + guard let html = EquationResourcePolicy.bootstrapHTML() else { throw Failure.unavailable } + #if DEBUG + diagnostic?.begin(.rules) + #endif + let rules: EquationRules = try await bounded { finish in + WKContentRuleListStore.default().compileContentRuleList( + forIdentifier: "maple-equation-deny-network-v1", + encodedContentRuleList: EquationResourcePolicy.contentRules + ) { list, _ in + if let list { finish(.success(EquationRules(list))) } else { finish(.failure(Failure.unavailable)) } + } + } + try Task.checkCancellation() + guard !stopped else { throw Failure.cancelled } + let config = WKWebViewConfiguration() + config.websiteDataStore = .nonPersistent() + config.setURLSchemeHandler(assets, forURLScheme: EquationResourcePolicy.scheme) + config.userContentController.add(rules.value) + config.preferences.javaScriptCanOpenWindowsAutomatically = false + config.allowsInlineMediaPlayback = false + config.mediaTypesRequiringUserActionForPlayback = .all + let web = WKWebView(frame: CGRect(x: 0, y: 0, width: 1024, height: 1), configuration: config) + web.isOpaque = false + web.backgroundColor = .clear + web.underPageBackgroundColor = .clear + web.scrollView.backgroundColor = .clear + web.scrollView.isScrollEnabled = false + web.isInspectable = false + web.navigationDelegate = navigation + web.uiDelegate = navigation + webView = web + #if DEBUG + diagnostic?.begin(.navigation) + #endif + try await bounded(timeout: .seconds(12)) { finish in + navigation.loaded = finish + guard let host, host.renderer == nil else { finish(.failure(Failure.unavailable)); return } + host.renderer = self + // Initial SwiftUI mounting shares the existing navigation deadline. + // One callback is cancelled with this renderer; no polling or retry. + pendingMount = { [weak self, weak web] in + guard let self, let web, !stopped, let host = self.host, + host.renderer === self, let window = host.window, + window.windowScene?.activationState == .foregroundActive, + !window.bounds.isEmpty + else { return } + pendingMount = nil + #if DEBUG + diagnostic?.record(.hostFound) + #endif + let container = UIView(frame: Self.outsideFrame(host: host, window: window)) + container.isUserInteractionEnabled = false + container.isAccessibilityElement = false + container.accessibilityElementsHidden = true + container.clipsToBounds = true + self.container = container + host.addSubview(container) + container.addSubview(web) + guard container.window === window, web.window === window else { + finish(.failure(Failure.unavailable)); invalidate(); return + } + attachedWindow = window + #if DEBUG + diagnostic?.record(.hostAttached) + let pending = web.loadHTMLString(html, baseURL: nil) + if pending == nil { diagnostic?.record(.loadReturnedNil) } + #else + web.loadHTMLString(html, baseURL: nil) + #endif + } + resumeMount() + } as Void + return web + } + + fileprivate func resumeMount() { pendingMount?() } + + fileprivate func hostMovedToWindow() { + if container != nil || host?.window == nil { invalidate() } else { resumeMount() } + } + + fileprivate func updateHostLayout() { + guard let container else { return } + guard let host, let window = attachedWindow, host.window === window, + window.windowScene?.activationState == .foregroundActive + else { invalidate(); return } + let frame = Self.outsideFrame(host: host, window: window) + if container.frame != frame { container.frame = frame } + } + + private static func outsideFrame(host: UIView, window: UIWindow) -> CGRect { + host.convert( + CGRect(x: window.bounds.maxX + 1, y: window.bounds.maxY + 1, width: 4096, height: 2048), + from: window) + } + + #if DEBUG + /// Synthetic fixture only; loopback-only positive-control canary. This + /// is compiled out of release and is never reachable from message data. + func exerciseContainmentForTesting(endpoint: URL) async throws -> Bool { + guard endpoint.scheme == "http", endpoint.host == "127.0.0.1", endpoint.port != nil, + endpoint.user == nil, endpoint.password == nil, endpoint.query == nil + else { throw Failure.rejected } + let permit = try await EquationWorkAdmission.shared.acquire() + defer { invalidate(); EquationWorkAdmission.shared.release(permit) } + let web = try await startSession() + return try await bounded { finish in + web.callAsyncJavaScript( + Self.containmentProbe, arguments: ["endpoint": endpoint.absoluteString], in: nil, in: .page + ) { result in + if case .success(let value) = result { + finish(.success((value as? Bool) == true)) + } else { + finish(.failure(Failure.unavailable)) + } + } + } + } + + private static let containmentProbe = #""" + const pending = []; + const attempt = body => { try { body(); } catch (_) {} }; + attempt(() => pending.push(fetch(endpoint + '/fetch').catch(() => {}))); + attempt(() => pending.push(fetch(endpoint.replace('http:', 'https:') + '/https').catch(() => {}))); + attempt(() => { const x = new XMLHttpRequest(); x.open('GET', endpoint + '/xhr'); x.timeout = 500; x.send(); }); + attempt(() => { const w = new WebSocket(endpoint.replace('http:', 'ws:') + '/websocket'); w.onerror = () => {}; }); + attempt(() => { const i = new Image(); i.src = endpoint + '/image'; document.body.append(i); }); + attempt(() => { const i = new Image(); i.src = endpoint.replace('http:', '') + '/relative-image'; document.body.append(i); }); + attempt(() => { const s = document.createElement('link'); s.rel = 'stylesheet'; s.href = endpoint + '/stylesheet'; document.head.append(s); }); + attempt(() => { const s = document.createElement('style'); s.textContent = '@import url("' + endpoint + '/import");'; document.head.append(s); }); + attempt(() => pending.push(new FontFace('probe', 'url("' + endpoint + '/font")').load().catch(() => {}))); + attempt(() => { const f = document.createElement('iframe'); f.src = endpoint + '/frame'; document.body.append(f); }); + attempt(() => navigator.sendBeacon(endpoint + '/beacon', 'synthetic')); + attempt(() => { const f = document.createElement('form'); f.action = endpoint + '/form'; document.body.append(f); f.submit(); }); + attempt(() => window.open(endpoint + '/popup')); + attempt(() => { const a = document.createElement('a'); a.href = endpoint + '/download'; a.download = 'synthetic'; document.body.append(a); a.click(); }); + await Promise.race([Promise.allSettled(pending), new Promise(resolve => setTimeout(resolve, 500))]); + attempt(() => { window.location.href = endpoint + '/navigation'; }); + return true; + """# + #endif + + func invalidate() { + stopped = true + pendingMount = nil + navigation.loaded?(.failure(Failure.cancelled)) + navigation.loaded = nil + webView?.stopLoading() + webView?.navigationDelegate = nil + webView?.uiDelegate = nil + webView?.configuration.userContentController.removeAllUserScripts() + webView?.removeFromSuperview() + if let container { + container.removeFromSuperview() + #if DEBUG + if container.superview == nil, webView?.window == nil { diagnostic?.record(.hostDetached) } + #endif + self.container = nil + } + if host?.renderer === self { host?.renderer = nil } + attachedWindow = nil + webView = nil + } + + private func bounded( + timeout: Duration = .seconds(3), + _ start: (@escaping @MainActor (Result) -> Void) -> Void + ) async throws -> Value { + let gate = EquationOperation() + #if DEBUG + let observer = diagnostic + let phase = observer?.phase + let navigationBegan = phase == .navigation ? ContinuousClock.now : nil + defer { + // Observe the winning result only after the await resolves. + // A late callback has no continuation and cannot replace it. + if let phase { observer?.finish(phase, outcome: gate.outcome) } + if let navigationBegan, let finished = gate.finishedAt { + observer?.recordNavigationLatency( + navigationBegan.duration(to: finished), deadline: timeout) + } + } + #endif + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + gate.continuation = continuation + guard !Task.isCancelled, !stopped else { + gate.finish(.failure(Failure.cancelled)); return + } + gate.timer = Task { @MainActor [weak self] in + do { try await Task.sleep(for: timeout) } catch { return } + gate.finish(.failure(Failure.timedOut)) + self?.invalidate() + } + start { result in gate.finish(result) } + } + } onCancel: { + Task { @MainActor [weak self] in + gate.finish(.failure(Failure.cancelled)) + self?.invalidate() + } + } + } + } + + /// Each MessageView mounts exactly one noninteractive host in its own window. + /// It holds no message data and never searches application or scene windows. + @MainActor + final class EquationLifecycleWindowHost: UIView { + weak var renderer: EquationRenderer? + + override func didMoveToWindow() { + super.didMoveToWindow() + renderer?.hostMovedToWindow() + } + + override func layoutSubviews() { + super.layoutSubviews() + renderer?.updateHostLayout() + } + + func resumeMount() { + renderer?.resumeMount() + renderer?.updateHostLayout() + } + } + + struct EquationLifecycleWindowAnchor: UIViewRepresentable { + let host: EquationLifecycleWindowHost + + func makeUIView(context: Context) -> EquationLifecycleWindowHost { + host.isUserInteractionEnabled = false + host.isAccessibilityElement = false + host.accessibilityElementsHidden = true + return host + } + + func updateUIView(_ uiView: EquationLifecycleWindowHost, context: Context) { uiView.resumeMount() } + + static func dismantleUIView(_ uiView: EquationLifecycleWindowHost, coordinator: ()) { + uiView.renderer?.invalidate() + } + } + + @MainActor + private final class EquationRules { + let value: WKContentRuleList + init(_ value: WKContentRuleList) { self.value = value } + } + + @MainActor + private final class EquationOperation { + var continuation: CheckedContinuation? + var timer: Task? + #if DEBUG + private(set) var outcome = EquationPhaseDiagnostic.Outcome.none + private(set) var finishedAt: ContinuousClock.Instant? + #endif + func finish(_ result: Result) { + let pending = continuation + #if DEBUG + if pending != nil { + finishedAt = ContinuousClock.now + switch result { + case .success: outcome = .completed + case .failure(let error): + switch error { + case EquationRenderer.Failure.timedOut: outcome = .timedOut + case EquationRenderer.Failure.cancelled, is CancellationError: outcome = .cancelled + default: outcome = .failed + } + } + } + #endif + continuation = nil + timer?.cancel() + timer = nil + pending?.resume(with: result) + } + } + + #if DEBUG + /// Fixed numeric observations only; no source, URL, identity or error payload. + @MainActor @Observable + final class EquationPhaseDiagnostic { + enum Phase: Int { case none, rules, navigation, metric, snapshot } + enum Outcome: Int { case none, completed, timedOut, cancelled, failed } + enum Detail: Int, CaseIterable { + case actions, actionAllowed, actionDenied, responseAllowed, responseDenied + case didStart, didCommit, didFinish, didFail, provisionalFailed, processTerminated + case loadReturnedNil, assetStarted, assetFinished, assetRejected + case hostFound, hostAttached, hostDetached + } + + private var details = Array(repeating: 0, count: Detail.allCases.count) + private(set) var detailSaturated = false + private(set) var lastRejectionBits = 0 + private(set) var rejectionBits = 0 + + func record(_ detail: Detail) { + guard details[detail.rawValue] < 1_024 else { detailSaturated = true; return } + details[detail.rawValue] += 1 + } + + func count(_ detail: Detail) -> Int { details[detail.rawValue] } + + func action(allowed: Bool, started: Bool, bootstrap: Bool, mainFrame: Bool, other: Bool) { + record(.actions) + record(allowed ? .actionAllowed : .actionDenied) + guard !allowed else { return } + // Bits identify failed terms of the unchanged admission predicate. + lastRejectionBits = + (started ? 1 : 0) | (bootstrap ? 0 : 2) + | (mainFrame ? 0 : 4) | (other ? 0 : 8) + rejectionBits |= lastRejectionBits + } + + private(set) var phase = Phase.none + private(set) var lastCompleted = Phase.none + private(set) var timedOutPhase = Phase.none + private(set) var outcome = Outcome.none + private var starts = [0, 0, 0, 0, 0] + private var completions = [0, 0, 0, 0, 0] + private(set) var navigationDeadlineMilliseconds = 0 + private(set) var navigationElapsedMilliseconds = 0 + private(set) var navigationCrossedOriginalDeadline = false + private(set) var navigationElapsedCapped = false + + func recordNavigationLatency(_ elapsed: Duration, deadline: Duration) { + let deadlineParts = deadline.components + let deadlineMilliseconds = + Double(deadlineParts.seconds) * 1_000 + + Double(deadlineParts.attoseconds) / 1_000_000_000_000_000 + navigationDeadlineMilliseconds = Int(min(max(deadlineMilliseconds, 0), 60_000)) + let parts = elapsed.components + let milliseconds = Double(parts.seconds) * 1_000 + Double(parts.attoseconds) / 1_000_000_000_000_000 + navigationElapsedMilliseconds = Int(min(max(milliseconds, 0), 60_000)) + // Retain the original deadline as a historical diagnostic comparator. + navigationCrossedOriginalDeadline = elapsed >= .seconds(3) + navigationElapsedCapped = milliseconds > 60_000 + } + + func begin(_ phase: Phase) { + self.phase = phase + outcome = .none + starts[phase.rawValue] += 1 + } + + func finish(_ phase: Phase, outcome: Outcome) { + self.outcome = outcome + if outcome == .completed { + lastCompleted = phase + completions[phase.rawValue] += 1 + } else if outcome == .timedOut { + timedOutPhase = phase + } + } + + func started(_ phase: Phase) -> Int { starts[phase.rawValue] } + func completed(_ phase: Phase) -> Int { completions[phase.rawValue] } + } + #endif + + private struct EquationMetric: Sendable { + let width: Double, height: Double, descent: Double + init?(_ raw: Any?) { + guard let object = raw as? [String: NSNumber], + let width = object["width"]?.doubleValue, let height = object["height"]?.doubleValue, + let descent = object["descent"]?.doubleValue, + width.isFinite, height.isFinite, descent.isFinite, + (1...4096).contains(width), (1...2048).contains(height), + (0...height).contains(descent) + else { return nil } + self.width = width; self.height = height; self.descent = descent + } + } + + @MainActor + private final class EquationAssets: NSObject, WKURLSchemeHandler { + #if DEBUG + weak var diagnostic: EquationPhaseDiagnostic? + #endif + func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) { + guard let url = urlSchemeTask.request.url, let path = EquationResourcePolicy.path(for: url), + let root = Bundle.module.url(forResource: "EquationRenderer", withExtension: nil), + let data = try? Data(contentsOf: root.appendingPathComponent(path)), data.count <= 1_048_576 + else { + #if DEBUG + diagnostic?.record(.assetRejected) + #endif + urlSchemeTask.didFailWithError(EquationRenderer.Failure.rejected); return + } + let mime: String + switch url.pathExtension { + case "html": mime = "text/html" + case "js": mime = "application/javascript" + case "css": mime = "text/css" + case "woff2": mime = "font/woff2" + default: + #if DEBUG + diagnostic?.record(.assetRejected) + #endif + urlSchemeTask.didFailWithError(EquationRenderer.Failure.rejected); return + } + #if DEBUG + diagnostic?.record(.assetStarted) + #endif + urlSchemeTask.didReceive( + URLResponse( + url: url, mimeType: mime, expectedContentLength: data.count, + textEncodingName: mime == "font/woff2" ? nil : "utf-8")) + urlSchemeTask.didReceive(data) + urlSchemeTask.didFinish() + #if DEBUG + diagnostic?.record(.assetFinished) + #endif + } + func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) {} + } + + @MainActor + private final class EquationNavigation: NSObject, WKNavigationDelegate, WKUIDelegate { + var loaded: ((Result) -> Void)? + private var policy = EquationBootstrapPolicy() + #if DEBUG + weak var diagnostic: EquationPhaseDiagnostic? + + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + diagnostic?.record(.didStart) + } + func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { + diagnostic?.record(.didCommit) + } + #endif + func webView( + _ webView: WKWebView, decidePolicyFor action: WKNavigationAction, + decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void + ) { + #if DEBUG + let wasStarted = policy.started + #endif + let allowed = policy.admit( + action.request.url, mainFrame: action.targetFrame?.isMainFrame == true, + other: action.navigationType == .other) + #if DEBUG + diagnostic?.action( + allowed: allowed, started: wasStarted, + bootstrap: action.request.url?.absoluteString == EquationResourcePolicy.bootstrap.absoluteString, + mainFrame: action.targetFrame?.isMainFrame == true, other: action.navigationType == .other) + #endif + decisionHandler(allowed ? .allow : .cancel) + } + func webView( + _ webView: WKWebView, decidePolicyFor response: WKNavigationResponse, + decisionHandler: @escaping @MainActor @Sendable (WKNavigationResponsePolicy) -> Void + ) { + let allowed = policy.allowsResponse(response.response.url, mainFrame: response.isForMainFrame) + #if DEBUG + diagnostic?.record(allowed ? .responseAllowed : .responseDenied) + #endif + decisionHandler(allowed ? .allow : .cancel) + } + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + #if DEBUG + diagnostic?.record(.didFinish) + #endif + loaded?(.success(())); loaded = nil + } + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + #if DEBUG + diagnostic?.record(.didFail) + #endif + loaded?(.failure(EquationRenderer.Failure.unavailable)); loaded = nil + } + func webView( + _ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error + ) { + #if DEBUG + diagnostic?.record(.provisionalFailed) + #endif + loaded?(.failure(EquationRenderer.Failure.unavailable)); loaded = nil + } + func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { + #if DEBUG + diagnostic?.record(.processTerminated) + #endif + loaded?(.failure(EquationRenderer.Failure.unavailable)); loaded = nil + } + func webView( + _ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures + ) -> WKWebView? { nil } + func webView( + _ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin, + initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType, + decisionHandler: @escaping @MainActor @Sendable (WKPermissionDecision) -> Void + ) { decisionHandler(.deny) } + } + + struct EquationAttachment: Attachment { + let source: String, png: Data + let lease: EquationRasterLease + let width: Double, height: Double, descent: Double + static func == (lhs: Self, rhs: Self) -> Bool { lhs.lease === rhs.lease } + func hash(into hasher: inout Hasher) { hasher.combine(ObjectIdentifier(lease)) } + var description: String { source } + var selectionStyle: AttachmentSelectionStyle { .text } + var body: some View { + if let image = UIImage(data: png) { + Image(uiImage: image).renderingMode(.template).resizable().foregroundStyle(.primary) + .accessibilityLabel(source) + } + } + func sizeThatFits(_ proposal: ProposedViewSize, in environment: TextEnvironmentValues) -> CGSize { + let scale = scale(in: environment) + return CGSize(width: width * scale, height: height * scale) + } + func baselineOffset(in environment: TextEnvironmentValues) -> CGFloat { -descent * scale(in: environment) } + func pngData() -> Data? { png } + private func scale(in environment: TextEnvironmentValues) -> CGFloat { + // The raster has a 20-point CSS basis; scale with the same body text category. + let points: CGFloat + switch environment.dynamicTypeSize { + case .xSmall: points = 14 + case .small: points = 15 + case .medium: points = 16 + case .large: points = 17 + case .xLarge: points = 19 + case .xxLarge: points = 21 + case .xxxLarge: points = 23 + case .accessibility1: points = 28 + case .accessibility2: points = 33 + case .accessibility3: points = 40 + case .accessibility4: points = 47 + case .accessibility5: points = 53 + @unknown default: points = 17 + } + return points / 20 + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Rendering/EquationResourcePolicy.swift b/apps/maple-ios/Sources/MapleAppUI/Rendering/EquationResourcePolicy.swift new file mode 100644 index 000000000..0c4efb560 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Rendering/EquationResourcePolicy.swift @@ -0,0 +1,62 @@ +import Foundation + +/// The equation process can read only these packaged assets. It cannot resolve a +/// renderer-controlled path, query, network origin, or credential-bearing URL. +enum EquationResourcePolicy { + static let scheme = "maple-equation" + static let origin = "maple-equation://bundle" + static let bootstrap = URL(string: "about:blank")! + static let fonts: Set = [ + "AMS-Regular", "Caligraphic-Bold", "Caligraphic-Regular", "Fraktur-Bold", + "Fraktur-Regular", "Main-Bold", "Main-BoldItalic", "Main-Italic", "Main-Regular", + "Math-BoldItalic", "Math-Italic", "SansSerif-Bold", "SansSerif-Italic", "SansSerif-Regular", + "Script-Regular", "Size1-Regular", "Size2-Regular", "Size3-Regular", "Size4-Regular", + "Typewriter-Regular", + ] + static let paths: Set = Set(["index.html", "renderer.js", "renderer.css", "katex.min.js", "katex.min.css"]) + .union(fonts.map { "fonts/KaTeX_\($0).woff2" }) + + static func path(for url: URL) -> String? { + guard url.scheme == scheme, url.host == "bundle", url.port == nil, + url.user == nil, url.password == nil, url.query == nil, url.fragment == nil, + !url.absoluteString.contains("%"), !url.absoluteString.contains("\\") + else { return nil } + let path = String(url.path.dropFirst()) + guard url.path == "/" + path, paths.contains(path) else { return nil } + return path + } + + static func resource(_ path: String) -> URL? { + guard paths.contains(path), let root = Bundle.module.url(forResource: "EquationRenderer", withExtension: nil) + else { return nil } + return root.appendingPathComponent(path) + } + + /// The bootstrap is packaged application code, never message-derived HTML. + static func bootstrapHTML() -> String? { + guard let url = resource("index.html"), let data = try? Data(contentsOf: url), + !data.isEmpty, data.count <= 1_048_576 + else { return nil } + return String(data: data, encoding: .utf8) + } + + static let contentRules = + #"[{"trigger":{"url-filter":".*"},"action":{"type":"block"}},{"trigger":{"url-filter":"^maple-equation://bundle/"},"action":{"type":"ignore-previous-rules"}}]"# +} + +/// Only the internally initiated document load may navigate this web view. +struct EquationBootstrapPolicy { + private(set) var started = false + + mutating func admit(_ url: URL?, mainFrame: Bool, other: Bool) -> Bool { + guard !started, mainFrame, other, + url?.absoluteString == EquationResourcePolicy.bootstrap.absoluteString + else { return false } + started = true + return true + } + + func allowsResponse(_ url: URL?, mainFrame: Bool) -> Bool { + started && mainFrame && url?.absoluteString == EquationResourcePolicy.bootstrap.absoluteString + } +} diff --git a/apps/maple-ios/Sources/MapleAppUI/Rendering/EquationWorkLimits.swift b/apps/maple-ios/Sources/MapleAppUI/Rendering/EquationWorkLimits.swift new file mode 100644 index 000000000..b072ff0e3 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Rendering/EquationWorkLimits.swift @@ -0,0 +1,101 @@ +import Foundation +import Synchronization + +/// Process-wide admission contains only opaque tickets and continuations, never +/// account identities, expressions or images. A canceled queued task is removed. +@MainActor final class EquationWorkAdmission { + enum Failure: Error, Equatable { case queueFull } + static let shared = EquationWorkAdmission() + private struct Waiter { + let id: UUID + let continuation: CheckedContinuation + } + + private var active: UUID? + private var waiters: [Waiter] = [] + var activeCount: Int { active == nil ? 0 : 1 } + var waitingCount: Int { waiters.count } + + /// Release the returned ticket in a defer around the complete renderer + /// lifetime. Cancellation after admission never prematurely releases another + /// task's slot; the active renderer remains responsible for its own cleanup. + func acquire() async throws -> UUID { + let id = UUID() + let granted: UUID = try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + guard !Task.isCancelled else { + continuation.resume(throwing: CancellationError()) + return + } + if active == nil { + active = id + continuation.resume(returning: id) + } else if waiters.count < 16 { + waiters.append(Waiter(id: id, continuation: continuation)) + } else { + continuation.resume(throwing: Failure.queueFull) + } + } + } onCancel: { + Task { @MainActor [weak self] in self?.cancelQueued(id) } + } + // Cancellation may race a release that granted this waiter. The cancel + // handler only owns queue removal; this continuation owns a granted slot. + if Task.isCancelled { + release(granted) + throw CancellationError() + } + return granted + } + + func release(_ id: UUID) { + guard active == id else { return } + active = nil + guard !waiters.isEmpty else { return } + let next = waiters.removeFirst() + active = next.id + next.continuation.resume(returning: next.id) + } + + private func cancelQueued(_ id: UUID) { + guard let index = waiters.firstIndex(where: { $0.id == id }) else { return } + waiters.remove(at: index).continuation.resume(throwing: CancellationError()) + } +} + +/// A reservation accounts for retained decoded raster cost across all messages. +/// It contains no content and is returned only when the aggregate budget fits. +final class EquationRasterBudget: Sendable { + static let shared = EquationRasterBudget() + let capacity: Int + private let retained = Mutex(0) + + init(capacity: Int = 16 * 1024 * 1024) { self.capacity = max(0, capacity) } + var reservedBytes: Int { retained.withLock { $0 } } + + func reserve(bytes: Int) -> EquationRasterLease? { + guard bytes > 0, bytes <= capacity else { return nil } + let accepted = retained.withLock { count in + guard count <= capacity - bytes else { return false } + count += bytes + return true + } + return accepted ? EquationRasterLease(bytes: bytes, budget: self) : nil + } + + fileprivate func release(bytes: Int) { + retained.withLock { $0 -= bytes } + } +} + +/// Attachment copies share this immutable lease. Only the final release returns +/// the reservation, on whichever thread drops the last attachment reference. +final class EquationRasterLease: Sendable { + let bytes: Int + private let budget: EquationRasterBudget + fileprivate init(bytes: Int, budget: EquationRasterBudget) { + self.bytes = bytes + self.budget = budget + } + deinit { budget.release(bytes: bytes) } +} diff --git a/apps/maple-ios/Sources/MapleAppUI/Rendering/MessageCodeHighlighter.swift b/apps/maple-ios/Sources/MapleAppUI/Rendering/MessageCodeHighlighter.swift new file mode 100644 index 000000000..1615cb2f6 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Rendering/MessageCodeHighlighter.swift @@ -0,0 +1,79 @@ +import Foundation + +struct MessageCodeToken: Sendable, Equatable { + enum Kind: Sendable { case plain, keyword, string, comment, number } + let text: String + let kind: Kind +} + +/// Linear lexical highlighting; no JavaScript, regex engine, URLs or process-wide cache. +enum MessageCodeHighlighter { + static func tokens(_ source: String, language: String, byteLimit: Int = 65_536) -> [MessageCodeToken] { + let language = language.lowercased() + let supported: Set = [ + "swift", "rust", "rs", "javascript", "js", "typescript", "ts", + "tsx", "jsx", "python", "py", "go", "json", "c", "cpp", "java", + "kotlin", "sh", "bash", "zsh", "ruby", "rb", "sql", "css", + ] + guard source.utf8.count <= byteLimit, supported.contains(language) else { + return [.init(text: source, kind: .plain)] + } + let keywords: Set = [ + "actor", "as", "async", "await", "break", "case", "catch", "class", + "const", "continue", "def", "defer", "delete", "do", "else", "enum", "export", "extends", + "false", "fn", "for", "from", "func", "function", "guard", "if", "impl", "import", "in", + "interface", "is", "let", "match", "mut", "new", "nil", "null", "None", "private", "pub", + "public", "return", "self", "static", "struct", "super", "switch", "throw", "throws", + "trait", "true", "try", "type", "typeof", "undefined", "use", "var", "void", "while", + "with", "yield", "SELECT", "FROM", "WHERE", "JOIN", "ON", "AS", "AND", "OR", "NULL", + ] + let hashComments = ["python", "py", "sh", "bash", "zsh", "ruby", "rb"].contains(language) + let chars = Array(source) + var tokens: [MessageCodeToken] = [], i = 0 + func isWord(_ c: Character) -> Bool { c.isLetter || c.isNumber || c == "_" || c == "$" } + func append(_ start: Int, _ end: Int, _ kind: MessageCodeToken.Kind) { + tokens.append(.init(text: String(chars[start.. PreparedMessage { + let markup = try await parser.parse(source) + try Task.checkCancellation() + guard !markup.isPlainText else { + return .init(revision: source, content: AttributedString(source), codeBlocks: [:], formulas: []) + } + let parsed = try AttributedString( + markdown: markup.markdown, + options: .init( + interpretedSyntax: .full, failurePolicy: .returnPartiallyParsedIfPossible, + appliesSourcePositionAttributes: true)) + var content = try MessageMarkdownParity.normalize(parsed, source: markup.markdown) + for run in Array(content.runs) { + content[run.range].imageURL = nil + content[run.range].textual.emojiURL = nil + if let url = run.link, + !["https", "http"].contains(url.scheme?.lowercased() ?? "") || url.host == nil || url.user != nil + || url.password != nil + { + content[run.range].link = nil + } + } + // Restore literal streaming suffixes only after Markdown processing so + // Foundation cannot consume TeX backslashes. Quoted/list code can contain + // placeholders too; those are restored as code and never sent to KaTeX. + for literal in markup.literals { + if let range = content.range(of: literal.id) { + var replacement = AttributedString(literal.source) + replacement.presentationIntent = content[range].presentationIntent + content.replaceSubrange(range, with: replacement) + } + } + var formulas: [MessageFormula] = [] + for formula in markup.formulas { + if let range = content.range(of: formula.id), + content[range].presentationIntent?.components.contains(where: { + if case .codeBlock = $0.kind { return true }; return false + }) == true + { + var replacement = AttributedString(formula.original) + replacement.presentationIntent = content[range].presentationIntent + content.replaceSubrange(range, with: replacement) + } else { + formulas.append(formula) + } + } + // Assign quoted/nested fences an owned hint too, so no code path lazily + // initializes the dependency's JS highlighter. Indented prose was normalized above. + var ownedBlocks = markup.codeBlocks + for run in Array(content.runs) { + guard var intent = run.presentationIntent, + let index = intent.components.firstIndex(where: { + if case .codeBlock = $0.kind { return true } + return false + }), + case .codeBlock(let hint) = intent.components[index].kind, + hint.flatMap({ ownedBlocks[$0] }) == nil + else { continue } + let key = "maplenested\(intent.components[index].identity)" + let existing = ownedBlocks[key]?.source ?? "" + ownedBlocks[key] = .init(language: hint ?? "", source: existing + String(content[run.range].characters)) + intent.components[index].kind = .codeBlock(languageHint: key) + content[run.range].presentationIntent = intent + } + let blocks = ownedBlocks.mapValues { block in + PreparedCode( + block: block, tokens: MessageCodeHighlighter.tokens(block.source, language: block.language)) + } + try Task.checkCancellation() + return .init(revision: source, content: content, codeBlocks: blocks, formulas: formulas) + } + + func resolve(_ draft: PreparedMessage, equations: [String: AnyAttachment]) throws -> PreparedMessage { + var content = draft.content + for formula in draft.formulas { + try Task.checkCancellation() + if let range = content.range(of: formula.id) { + var replacement = AttributedString(equations[formula.id] == nil ? formula.original : "\u{FFFC}") + replacement.presentationIntent = content[range].presentationIntent + replacement.inlinePresentationIntent = content[range].inlinePresentationIntent + if let equation = equations[formula.id] { replacement.textual.attachment = equation } + content.replaceSubrange(range, with: replacement) + } + } + return .init( + revision: draft.revision, content: content, codeBlocks: draft.codeBlocks, formulas: [], + equationSources: draft.formulas.isEmpty ? draft.equationSources : draft.formulas.map(\.original), + equationVersion: equations.keys.sorted().joined(separator: ",")) + } + +} diff --git a/apps/maple-ios/Sources/MapleAppUI/Rendering/MessageEquationLifecycleFixtureView.swift b/apps/maple-ios/Sources/MapleAppUI/Rendering/MessageEquationLifecycleFixtureView.swift new file mode 100644 index 000000000..0227eeef3 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Rendering/MessageEquationLifecycleFixtureView.swift @@ -0,0 +1,281 @@ +#if os(iOS) && DEBUG + import Foundation + import Observation + import SwiftUI + import Textual + import UIKit + + /// Only fixed synthetic content is hosted. The actual MessageView owns all + /// preparation, publication, generation changes and disappearance callbacks. + struct MessageEquationLifecycleFixtureView: View { + @State private var probe = MessageEquationLifecycleProbe() + @State private var generation = UUID() + @State private var revision = 0 + @State private var shown = true + @State private var coldPublished = 0 + @State private var coldInk = 0 + + var body: some View { + if ProcessInfo.processInfo.arguments.contains("--maple-renderer-cold-equations") { + VStack { + Text("Published equations: \(coldPublished); ink: \(coldInk)") + .accessibilityIdentifier("renderer.cold-state") + ScrollView { + MessageView( + text: coldSource, accountGeneration: generation, + diagnosticPublication: { + coldPublished = $0 + coldInk = $1 + } + ) + .accessibilityIdentifier("renderer.cold-message") + } + }.padding() + } else { + lifecycleContent + } + } + + private var coldSource: String { + EquationDiagnosticView.formulas.map { "$$\n" + $0 + "\n$$" }.joined(separator: "\n\n") + } + + private var lifecycleContent: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Button("Release equations") { probe.gate.release() } + .accessibilityIdentifier("renderer.lifecycle-release") + .disabled(probe.gate.pendingCount != 1) + Button("Pending revision") { revision += 1 } + .accessibilityIdentifier("renderer.lifecycle-revise") + } + HStack { + Button("Revoke generation") { + generation = UUID(); revision += 1 + } + .accessibilityIdentifier("renderer.lifecycle-revoke") + Button("Remove message") { shown = false } + .accessibilityIdentifier("renderer.lifecycle-remove") + } + Text("Published equations: \(probe.publishedCount)") + .font(.caption) + .accessibilityLabel(probe.snapshot(source: source, generation: generation, shown: shown)) + .accessibilityIdentifier("renderer.lifecycle-state") + ScrollView { + if shown { + MessageView( + text: source, accountGeneration: generation, diagnosticModel: probe.model, + diagnosticHost: probe.windowHost, + diagnosticAppearance: { appeared in + if appeared { probe.result.appeared += 1 } else { probe.result.disappeared += 1 } + } + ) + .accessibilityIdentifier("renderer.lifecycle-message") + } + } + }.padding() + } + + private var source: String { + """ + Synthetic revision \(revision). + + ```swift + let lifecycle = true + ``` + + Inline $x^2$. + + $$ + \\frac{a}{b} + \\frac{c}{d} = \\frac{ad + bc}{bd} + $$ + """ + } + } + + @MainActor @Observable + private final class MessageEquationLifecycleProbe { + let gate: MessageEquationLifecycleGate + let result: MessageEquationLifecycleResult + let model: MessageRenderModel + let windowHost: EquationLifecycleWindowHost + + init() { + let windowHost = EquationLifecycleWindowHost(frame: .zero) + self.windowHost = windowHost + let gate = MessageEquationLifecycleGate() + let result = MessageEquationLifecycleResult() + self.gate = gate + self.result = result + model = MessageRenderModel( + pause: { duration in try await gate.pause(duration) }, + equations: { formulas in + result.started += 1 + result.requested = formulas.count + result.rasterBytesBefore = EquationRasterBudget.shared.reservedBytes + result.rasterCapacity = EquationRasterBudget.shared.capacity + result.activeBefore = EquationWorkAdmission.shared.activeCount + result.queuedBefore = EquationWorkAdmission.shared.waitingCount + result.renderer = EquationPhaseDiagnostic() + let renderer = EquationRenderer( + diagnostic: result.renderer, host: windowHost) + defer { renderer.invalidate() } + do { + let rendered = try await renderer.render(formulas) + result.completed += 1 + result.rasterBytesAfter = EquationRasterBudget.shared.reservedBytes + result.attachments = rendered.count + result.ink = + rendered.values.filter { + EquationDiagnosticView.hasVisibleGlyphs($0.png) + }.count + return rendered.mapValues { AnyAttachment($0) } + } catch { + result.failed += 1 + result.rasterBytesAfter = EquationRasterBudget.shared.reservedBytes + switch error { + case is CancellationError: result.failure = "cancelled" + case EquationRenderer.Failure.cancelled: result.failure = "cancelled" + case EquationRenderer.Failure.timedOut: result.failure = "timedOut" + case EquationRenderer.Failure.rejected: result.failure = "rejected" + case EquationRenderer.Failure.unavailable: result.failure = "unavailable" + case EquationWorkAdmission.Failure.queueFull: result.failure = "queueFull" + default: result.failure = "other" + } + throw error + } + }) + } + + var publishedCount: Int { + model.prepared?.content.runs.filter { $0.textual.attachment != nil }.count ?? 0 + } + + func snapshot(source: String, generation: UUID, shown: Bool) -> String { + // No formula, revision text, UUID, account or arbitrary error leaves + // this diagnostic. The event counters are numeric and no log grows. + let values: [String: Any] = [ + "shown": shown, "prepared": model.prepared != nil, + "appeared": result.appeared, "disappeared": result.disappeared, + "currentSource": model.prepared?.revision == source, + "currentGeneration": model.generation == generation, + "sources": model.prepared?.equationSources.count ?? 0, + "published": publishedCount, "waiting": gate.pendingCount, + "entered": gate.entered, "released": gate.released, "cancelled": gate.cancelled, + "started": result.started, "completed": result.completed, "failed": result.failed, + "attachments": result.attachments, "ink": result.ink, "failure": result.failure, + "requested": result.requested, "rasterBytesBefore": result.rasterBytesBefore, + "rasterBytesAfter": result.rasterBytesAfter, "rasterCapacity": result.rasterCapacity, + "activeBefore": result.activeBefore, "queuedBefore": result.queuedBefore, + "rendererPhase": result.renderer.phase.rawValue, + "rendererLastCompletedPhase": result.renderer.lastCompleted.rawValue, + "rendererTimedOutPhase": result.renderer.timedOutPhase.rawValue, + "rendererOutcome": result.renderer.outcome.rawValue, + "rulesStarted": result.renderer.started(.rules), + "rulesCompleted": result.renderer.completed(.rules), + "navigationStarted": result.renderer.started(.navigation), + "navigationCompleted": result.renderer.completed(.navigation), + "navigationDeadlineMilliseconds": result.renderer.navigationDeadlineMilliseconds, + "navigationElapsedMilliseconds": result.renderer.navigationElapsedMilliseconds, + "navigationCrossedOriginalDeadline": result.renderer.navigationCrossedOriginalDeadline ? 1 : 0, + "navigationElapsedCapped": result.renderer.navigationElapsedCapped ? 1 : 0, + "metricStarted": result.renderer.started(.metric), + "metricCompleted": result.renderer.completed(.metric), + "snapshotStarted": result.renderer.started(.snapshot), + "snapshotCompleted": result.renderer.completed(.snapshot), + "navigationActions": result.renderer.count(.actions), + "navigationActionAllowed": result.renderer.count(.actionAllowed), + "navigationActionDenied": result.renderer.count(.actionDenied), + "navigationResponseAllowed": result.renderer.count(.responseAllowed), + "navigationResponseDenied": result.renderer.count(.responseDenied), + "navigationDidStart": result.renderer.count(.didStart), + "navigationDidCommit": result.renderer.count(.didCommit), + "navigationDidFinish": result.renderer.count(.didFinish), + "navigationDidFail": result.renderer.count(.didFail), + "navigationProvisionalFailed": result.renderer.count(.provisionalFailed), + "navigationProcessTerminated": result.renderer.count(.processTerminated), + "navigationLoadReturnedNil": result.renderer.count(.loadReturnedNil), + "assetsAllowedStarted": result.renderer.count(.assetStarted), + "assetsFinished": result.renderer.count(.assetFinished), + "assetsRejected": result.renderer.count(.assetRejected), + "navigationLastRejectionBits": result.renderer.lastRejectionBits, + "navigationRejectionBits": result.renderer.rejectionBits, + "navigationDetailSaturated": result.renderer.detailSaturated ? 1 : 0, + "windowHostRequested": 1, + "windowHostFound": result.renderer.count(.hostFound), + "windowHostAttached": result.renderer.count(.hostAttached), + "windowHostDetached": result.renderer.count(.hostDetached), + ] + guard let data = try? JSONSerialization.data(withJSONObject: values, options: [.sortedKeys]), + let text = String(data: data, encoding: .utf8) + else { return "diagnostic unavailable" } + return text + } + } + + @MainActor @Observable + private final class MessageEquationLifecycleResult { + var renderer = EquationPhaseDiagnostic() + var appeared = 0 + var disappeared = 0 + var started = 0 + var completed = 0 + var failed = 0 + var attachments = 0 + var ink = 0 + var failure = "none" + var requested = 0 + var rasterBytesBefore = 0 + var rasterBytesAfter = 0 + var rasterCapacity = 0 + var activeBefore = 0 + var queuedBefore = 0 + } + + @MainActor @Observable + private final class MessageEquationLifecycleGate { + var entered = 0 + var released = 0 + var cancelled = 0 + private(set) var pendingCount = 0 + @ObservationIgnored private var pending: [UUID: CheckedContinuation] = [:] + + func pause(_ duration: Duration) async throws { + guard duration == .milliseconds(180) else { + try await Task.sleep(for: duration) + return + } + let id = UUID() + entered += 1 + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + guard !Task.isCancelled else { + cancelled += 1 + continuation.resume(throwing: CancellationError()) + return + } + pending[id] = continuation + pendingCount = pending.count + } + } onCancel: { + Task { @MainActor [weak self] in self?.cancel(id) } + } + } + + func release() { + guard pending.count == 1, let id = pending.keys.first, + let continuation = pending.removeValue(forKey: id) + else { return } + pendingCount = pending.count + released += 1 + continuation.resume() + } + + private func cancel(_ id: UUID) { + guard let continuation = pending.removeValue(forKey: id) else { return } + pendingCount = pending.count + cancelled += 1 + continuation.resume(throwing: CancellationError()) + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Rendering/MessageMarkdownParity.swift b/apps/maple-ios/Sources/MapleAppUI/Rendering/MessageMarkdownParity.swift new file mode 100644 index 000000000..e22916d88 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Rendering/MessageMarkdownParity.swift @@ -0,0 +1,125 @@ +import Foundation + +/// Research enables GFM task markers and remark-breaks, and disables only +/// indented code. Work on Foundation's parsed nodes so links, inline code, and +/// fenced code are not rewritten by a source-wide regular expression. +enum MessageMarkdownParity { + static func normalize(_ parsed: AttributedString, source: String) throws -> AttributedString { + var content = parsed + // CommonMark advances source lines for CRLF, LF, and lone CR. Normalize + // separators only in this lookup copy; original source bytes stay intact. + let sourceLines = source.replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + .split(separator: "\n", omittingEmptySubsequences: false) + // Reverse order leaves earlier source ranges valid as text lengths change. + for run in Array(content.runs).reversed() { + try Task.checkCancellation() + guard var intent = run.presentationIntent, + let codeIndex = intent.components.firstIndex(where: { + if case .codeBlock = $0.kind { return true }; return false + }), case .codeBlock(let hint) = intent.components[codeIndex].kind, + let position = run.markdownSourcePosition, + let opening = openingLine(at: position, in: sourceLines) + else { continue } + let code = String(content[run.range].characters) + guard !isFence(source: opening, code: code, hint: hint) else { continue } + var prose = try AttributedString( + markdown: code.hasSuffix("\n") ? String(code.dropLast()) : code, + options: .init( + interpretedSyntax: .inlineOnlyPreservingWhitespace, + failurePolicy: .returnPartiallyParsedIfPossible)) + intent.components[codeIndex].kind = .paragraph + prose.presentationIntent = intent + content.replaceSubrange(run.range, with: prose) + } + + // Only the first run in each real list-item paragraph can be a task marker. + // Original source distinguishes an escaped \[ ] marker from task syntax. + var firstParagraphs = Set() + var taskRanges: [(Range, String, PresentationIntent)] = [] + for run in content.runs { + guard let intent = run.presentationIntent, + let paragraph = intent.components.first(where: { + if case .paragraph = $0.kind { return true }; return false + }), + intent.components.contains(where: { + if case .listItem = $0.kind { return true }; return false + }), firstParagraphs.insert(paragraph.identity).inserted, + run.inlinePresentationIntent?.contains(.code) != true, + let position = run.markdownSourcePosition, + let sourceRange = Range(position, in: source) + else { continue } + let original = source[sourceRange] + let text = String(content[run.range].characters) + let checked: Bool + if taskPrefix(original) == false, taskPrefix(text) == false { + checked = false + } else if taskPrefix(original) == true, taskPrefix(text) == true { + checked = true + } else { + continue + } + let end = content.characters.index(run.range.lowerBound, offsetBy: 3) + taskRanges.append((run.range.lowerBound.. String? { + // Foundation represents a block ending before a blank line with endColumn + // zero; its full Range initializer then returns nil. Fence classification + // needs only the valid opening position, not that absent ending offset. + guard position.startLine > 0, position.startLine <= lines.count, position.startColumn > 0 else { return nil } + let line = lines[position.startLine - 1] + guard position.startColumn - 1 <= line.utf8.count else { return nil } + return String(bytes: line.utf8.dropFirst(position.startColumn - 1), encoding: .utf8) + } + + private static func taskPrefix(_ text: S) -> Bool? { + guard text.hasPrefix("[ ]") || text.hasPrefix("[x]") || text.hasPrefix("[X]") else { return nil } + if let next = text.dropFirst(3).first, next != " " && next != "\t" { return nil } + return !text.hasPrefix("[ ]") + } + + private static func isFence(source: String, code: String, hint: String?) -> Bool { + // Foundation's source position for fenced code includes its opening fence, + // even inside a list or blockquote; indented code starts at code content. + // An indented literal ``` line is content, not an opening fence. Without + // a language hint, it appears verbatim as the first code line; a real + // no-language opening fence cannot also be the first body line, since it + // would close the block. Preserve language-tagged quoted/nested fences. + let firstSource = source.prefix(while: { !$0.isNewline }) + guard let marker = firstSource.first, marker == "`" || marker == "~", + firstSource.prefix(while: { $0 == marker }).count >= 3 + else { return false } + if hint != nil { return true } + let firstCode = code.prefix(while: { !$0.isNewline }) + return firstSource.trimmingTrailingHorizontalWhitespace != firstCode.trimmingTrailingHorizontalWhitespace + } +} + +extension Substring { + fileprivate var trimmingTrailingHorizontalWhitespace: Substring { + var result = self + while let last = result.last, last == " " || last == "\t" { result = result.dropLast() } + return result + } +} diff --git a/apps/maple-ios/Sources/MapleAppUI/Rendering/MessageMarkup.swift b/apps/maple-ios/Sources/MapleAppUI/Rendering/MessageMarkup.swift new file mode 100644 index 000000000..4ba5704b5 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Rendering/MessageMarkup.swift @@ -0,0 +1,237 @@ +import Foundation + +/// Resource limits apply to formatting work. Original message text is never truncated. +public struct MessageRenderLimits: Sendable, Equatable { + public var documentBytes = 262_144 + public var formulaBytes = 16_384 + public var formulaCount = 128 + public var formulaDepth = 64 + public var highlightedCodeBytes = 65_536 + public init() {} +} + +struct MessageFormula: Sendable, Equatable, Identifiable { + let id: String + let latex: String + let original: String + let display: Bool + let isWithinBudget: Bool +} + +struct MessageLiteral: Sendable { let id: String; let source: String } + +struct MessageCodeBlock: Sendable, Equatable { + let language: String + let source: String +} + +struct MessageMarkup: Sendable { + let original: String + let markdown: String + let formulas: [MessageFormula] + let codeBlocks: [String: MessageCodeBlock] + let isPlainText: Bool + var literals: [MessageLiteral] = [] +} + +/// Runs on its own executor, never during SwiftUI layout. No document cache or logging. +actor MessageMarkupParser { + func parse(_ text: String, limits: MessageRenderLimits = .init()) throws -> MessageMarkup { + try Task.checkCancellation() + // These sentinels are internal placeholders, not a second user-facing syntax. + guard text.utf8.count <= limits.documentBytes, !text.contains("\u{E000}maple-") else { + return .init(original: text, markdown: text, formulas: [], codeBlocks: [:], isPlainText: true) + } + var lexer = MarkupLexer(limits: limits) + let markdown = try lexer.process(text) + return .init( + original: text, markdown: markdown, formulas: lexer.formulas, + codeBlocks: lexer.codeBlocks, isPlainText: lexer.requiresRawFallback, literals: lexer.literals) + } +} + +private struct Fence { + let marker: Character + let length: Int + let language: String + let indent: String + + init?(_ line: String) { + let indentCount = line.prefix(while: { $0 == " " }).count + guard indentCount <= 3 else { return nil } + let body = line.dropFirst(indentCount) + guard let marker = body.first, marker == "`" || marker == "~" else { return nil } + let length = body.prefix(while: { $0 == marker }).count + guard length >= 3 else { return nil } + let suffix = body.dropFirst(length).trimmingCharacters(in: .whitespacesAndNewlines) + if marker == "`", suffix.contains("`") { return nil } + self.marker = marker + self.length = length + self.language = String(suffix.split(whereSeparator: \.isWhitespace).first ?? "") + self.indent = String(repeating: " ", count: indentCount) + } + + func closes(_ line: String) -> Bool { + let body = line.drop(while: { $0 == " " }) + return line.count - body.count <= 3 + && body.prefix(while: { $0 == marker }).count >= length + && body.drop(while: { $0 == marker }).allSatisfy(\.isWhitespace) + } +} + +private struct MarkupLexer { + let limits: MessageRenderLimits + var formulas: [MessageFormula] = [] + var codeBlocks: [String: MessageCodeBlock] = [:] + var literals: [MessageLiteral] = [] + var requiresRawFallback = false + + mutating func process(_ text: String) throws -> String { + let lines = text.split(separator: "\n", omittingEmptySubsequences: false) + var result = "", prose = "", code = "" + var active: Fence? + var token = "" + for (index, slice) in lines.enumerated() { + if index.isMultiple(of: 64) { try Task.checkCancellation() } + let line = String(slice), suffix = index + 1 < lines.count ? "\n" : "" + if let fence = active { + if fence.closes(line) { + codeBlocks[token] = .init(language: fence.language, source: code.droppingFinalLineFeed) + result += code + line + suffix + active = nil + code = "" + } else { + code += line + suffix + } + } else if let fence = Fence(line) { + result += inline(prose) + prose = "" + token = "maplecode\(codeBlocks.count)" + // Every fenced block uses our bounded highlighter. In particular a `math` + // fence must not enter a library's built-in equation/cache path. + result += + fence.indent + String(repeating: String(fence.marker), count: fence.length) + + token + suffix + active = fence + } else { + prose += line + suffix + } + } + if let active { + codeBlocks[token] = .init(language: active.language, source: code.droppingFinalLineFeed) + result += code + } + result += inline(prose) + return result + } + + mutating func inline(_ source: String) -> String { + let chars = Array(source) + var result = "", i = 0 + func matches(_ token: [Character], at index: Int) -> Bool { + index + token.count <= chars.count && Array(chars[index..<(index + token.count)]) == token + } + while i < chars.count { + // Link destinations and explicit autolinks are URL data, not math. + if matches(["]", "("], at: i) { + var end = i + 2, depth = 1 + while end < chars.count, depth > 0 { + if chars[end] == "\\", end + 1 < chars.count { end += 2; continue } + if chars[end] == "(" { depth += 1 } + if chars[end] == ")" { depth -= 1 } + end += 1 + } + result += String(chars[i.. i + opening.count, + (display || !chars[end - 1].isWhitespace) + { + break + } + if chars[end] == "\\", closing.first != "\\", end + 1 < chars.count { end += 2 } else { end += 1 } + } + guard end < chars.count else { + // One scan, then keep incomplete streaming input visible. Do not repeatedly + // rescan a suffix containing many unmatched delimiters. + result += literal(String(chars[i...])); break + } + let latex = String(chars[(i + opening.count).. String { + guard literals.count < limits.formulaCount else { requiresRawFallback = true; return source } + let id = "\u{E000}maple-literal-\(literals.count)\u{E001}" + literals.append(.init(id: id, source: source)) + return id + } + + func withinFormulaBudget(_ source: String) -> Bool { + guard source.utf8.count <= limits.formulaBytes else { return false } + var depth = 0, escaped = false, cells = 1 + for scalar in source.unicodeScalars { + if escaped { escaped = false; continue } + if scalar == "\\" { escaped = true; continue } + if scalar == "{" { depth += 1; if depth > limits.formulaDepth { return false } } + if scalar == "}" { depth -= 1; if depth < 0 { return false } } + if scalar == "&" { cells += 1; if cells > 4096 { return false } } + } + return depth == 0 + } +} + +private extension String { + var droppingFinalLineFeed: String { hasSuffix("\n") ? String(dropLast()) : self } +} diff --git a/apps/maple-ios/Sources/MapleAppUI/Rendering/MessageRenderModel.swift b/apps/maple-ios/Sources/MapleAppUI/Rendering/MessageRenderModel.swift new file mode 100644 index 000000000..01eb18ee5 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Rendering/MessageRenderModel.swift @@ -0,0 +1,123 @@ +import Foundation +import Observation +import Textual + +protocol MessagePreparation: Sendable { + func prepare(_ source: String) async throws -> PreparedMessage + func resolve(_ draft: PreparedMessage, equations: [String: AnyAttachment]) async throws -> PreparedMessage +} + +/// Throttles work without restarting the publication deadline for every token. +/// Equation enrichment has a separate cancellable quiet-period task, so it can +/// never delay ordinary streamed text. No state survives generation revocation. +@MainActor @Observable +final class MessageRenderModel { + private(set) var prepared: PreparedMessage? + private(set) var generation: UUID? + @ObservationIgnored private let worker: any MessagePreparation + @ObservationIgnored private let equations: + @MainActor @Sendable ([MessageFormula]) async throws -> [String: AnyAttachment] + @ObservationIgnored private let pause: @Sendable (Duration) async throws -> Void + @ObservationIgnored private var latest: Request? + @ObservationIgnored private var completedRequest: Request? + @ObservationIgnored private var epoch = UUID() + @ObservationIgnored private var parseTask: Task? + @ObservationIgnored private var equationTask: Task? + + init( + worker: any MessagePreparation = MessageDocumentWorker(), + pause: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) }, + equations: @escaping @MainActor @Sendable ([MessageFormula]) async throws -> [String: AnyAttachment] = { _ in + [:] + } + ) { + self.worker = worker + self.pause = pause + self.equations = equations + } + + func submit(text: String, generation: UUID?) { + let request = Request(text: text, generation: generation) + guard latest != request else { return } + if self.generation != generation { cancel() } + self.generation = generation + completedRequest = nil + latest = request + equationTask?.cancel() + equationTask = nil + if parseTask == nil { + let owner = epoch + parseTask = Task { @MainActor [weak self] in await self?.drain(owner: owner) } + } + } + + /// Retain only a completed projection during owned viewport removal or occlusion. + /// Rotate the epoch even when work is complete: late tasks cannot publish. + func suspend() { + epoch = UUID() + parseTask?.cancel() + equationTask?.cancel() + parseTask = nil + equationTask = nil + if latest != completedRequest { + latest = nil + prepared = nil + completedRequest = nil + } + } + + func cancel() { + suspend() + latest = nil + prepared = nil + completedRequest = nil + } + + private func drain(owner: UUID) async { + defer { if epoch == owner { parseTask = nil } } + while !Task.isCancelled, epoch == owner { + do { try await pause(.milliseconds(65)) } catch { return } + guard !Task.isCancelled, epoch == owner, let request = latest else { return } + do { + let draft = try await worker.prepare(request.text) + let plainEquations = try await worker.resolve(draft, equations: [:]) + guard !Task.isCancelled, epoch == owner else { return } + prepared = plainEquations + if latest == request { + if draft.formulas.isEmpty { + completedRequest = request + } else { + enrich(draft, request: request, owner: owner) + } + return + } + } catch { + guard !Task.isCancelled, epoch == owner else { return } + prepared = .init( + revision: request.text, content: AttributedString(request.text), codeBlocks: [:], formulas: []) + if latest == request { completedRequest = request; return } + } + } + } + + private func enrich(_ draft: PreparedMessage, request: Request, owner: UUID) { + equationTask = Task { @MainActor [weak self] in + guard let self else { return } + do { + try await pause(.milliseconds(180)) + guard !Task.isCancelled, epoch == owner, latest == request else { return } + let rendered = try await equations(draft.formulas) + let result = try await worker.resolve(draft, equations: rendered) + guard !Task.isCancelled, epoch == owner, latest == request else { return } + prepared = result + completedRequest = request + } catch { + // Complete original TeX remains selectable on a terminal failure. + guard !Task.isCancelled, epoch == owner, latest == request else { return } + completedRequest = request + } + } + } + + private struct Request: Equatable { let text: String; let generation: UUID? } +} diff --git a/apps/maple-ios/Sources/MapleAppUI/Rendering/MessageView.swift b/apps/maple-ios/Sources/MapleAppUI/Rendering/MessageView.swift new file mode 100644 index 000000000..576fa0287 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Rendering/MessageView.swift @@ -0,0 +1,369 @@ +#if os(iOS) + import Foundation + import SwiftUI + import Textual + import UIKit + import UniformTypeIdentifiers + + public enum MessageRenderTheme: String, Sendable { case automatic, light, dark } + public enum MessageRenderFont: String, Sendable { case manrope, system, serif } + + private struct RetainedMessageRenderingKey: EnvironmentKey { + static let defaultValue: @MainActor @Sendable () -> Bool = { false } + } + + extension EnvironmentValues { + var retainedMessageRendering: @MainActor @Sendable () -> Bool { + get { self[RetainedMessageRenderingKey.self] } + set { self[RetainedMessageRenderingKey.self] = newValue } + } + } + + #if DEBUG + private struct MessageRenderDisappearanceDiagnosticKey: EnvironmentKey { + static let defaultValue: @MainActor @Sendable (Bool, Bool) -> Void = { _, _ in } + } + + extension EnvironmentValues { + var messageRenderDisappearanceDiagnostic: @MainActor @Sendable (Bool, Bool) -> Void { + get { self[MessageRenderDisappearanceDiagnosticKey.self] } + set { self[MessageRenderDisappearanceDiagnosticKey.self] = newValue } + } + } + #endif + + /// Shared message surface for every supported iOS version. Its parser owns no network capability. + public struct MessageView: View { + private let text: String + private let theme: MessageRenderTheme + private let font: MessageRenderFont + private let accountGeneration: UUID? + private let onOpenLink: (URL) -> Void + private var preparedPublication: (@MainActor @Sendable () -> Void)? + #if DEBUG + private var diagnosticAppearance: ((Bool) -> Void)? + private var diagnosticPublication: ((Int, Int) -> Void)? + #endif + @Environment(\.colorScheme) private var colorScheme + @Environment(\.scenePhase) private var scenePhase + @Environment(\.legibilityWeight) private var legibilityWeight + @Environment(\.retainedMessageRendering) private var retainsRendering + #if DEBUG + @Environment(\.messageRenderDisappearanceDiagnostic) private var disappearanceDiagnostic + #endif + @State private var windowHost: EquationLifecycleWindowHost + @State private var model: MessageRenderModel + + public init( + text: String, theme: MessageRenderTheme = .automatic, + font: MessageRenderFont = .manrope, accountGeneration: UUID? = nil, + onOpenLink: @escaping (URL) -> Void + ) { + self.text = text + self.theme = theme + self.font = font + self.accountGeneration = accountGeneration + self.onOpenLink = onOpenLink + let host = EquationLifecycleWindowHost(frame: .zero) + _windowHost = State(initialValue: host) + _model = State( + initialValue: MessageRenderModel(equations: { formulas in + let renderer = EquationRenderer(host: host) + defer { renderer.invalidate() } + return try await renderer.render(formulas).mapValues { AnyAttachment($0) } + })) + } + + /// Internal presentation receipt; callers receive no message contents. + /// Publication is useful only for this exact submitted text and owner. + @MainActor func onPrepared(_ action: @escaping @MainActor @Sendable () -> Void) -> Self { + var copy = self; copy.preparedPublication = action; return copy + } + + #if DEBUG + // The fixture's observed renderer uses the same host mounted by this + // production body. Its gate only controls when synthetic work begins. + init( + text: String, accountGeneration: UUID, diagnosticModel: MessageRenderModel, + diagnosticHost: EquationLifecycleWindowHost, + diagnosticAppearance: @escaping (Bool) -> Void + ) { + self.init(text: text, theme: .light, accountGeneration: accountGeneration) { _ in } + _model = State(initialValue: diagnosticModel) + _windowHost = State(initialValue: diagnosticHost) + self.diagnosticAppearance = diagnosticAppearance + } + + // Observes publication through the unmodified production initializer; + // no injected parser, renderer, host, clock or equation warm-up. + init(text: String, accountGeneration: UUID, diagnosticPublication: @escaping (Int, Int) -> Void) { + self.init(text: text, theme: .light, accountGeneration: accountGeneration) { _ in } + self.diagnosticPublication = diagnosticPublication + } + #endif + + public var body: some View { + Group { + if let prepared = model.prepared, model.generation == accountGeneration { + StructuredText(prepared.presentationKey, parser: PreparedMarkup(content: prepared.content)) + .textual.textSelection(.enabled) + .textual.inlineStyle( + .default.code(.font(MapleTypography.inlineCode)) + .link(.foregroundColor(.primary), .underlineStyle(.single)) + ) + .textual.imageAttachmentLoader(NoMessageAttachments()) + .textual.emojiAttachmentLoader(NoMessageAttachments()) + .textual.codeBlockStyle(MessageCodeStyle(blocks: prepared.codeBlocks)) + .textual.tableStyle(MessageTableStyle()) + .textual.tableCellStyle(MessageTableCellStyle()) + .scrollIndicators(.visible) + .accessibilityElement(children: .contain) + .accessibilityLabel(Text("Message", bundle: .module)) + .accessibilityCustomContent( + AccessibilityCustomContentKey(Text("Equations", bundle: .module), id: "maple.equations"), + equationAccessibilityText(prepared), importance: .high) + } else { + Text(text).textSelection(.enabled) + } + } + .font(messageFont) + .fontWeight(legibilityWeight == .bold ? .semibold : .regular) + .frame(maxWidth: .infinity, alignment: .leading) + .background { + EquationLifecycleWindowAnchor(host: windowHost) + .frame(width: 0, height: 0) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + .environment(\.colorScheme, theme == .automatic ? colorScheme : theme == .dark ? .dark : .light) + .environment( + \.openURL, + OpenURLAction { url in + guard ["https", "http"].contains(url.scheme?.lowercased() ?? ""), + url.host != nil, url.user == nil, url.password == nil + else { return .discarded } + onOpenLink(url) + return .handled + } + ) + .onAppear { + #if DEBUG + diagnosticAppearance?(true) + #endif + model.submit(text: text, generation: accountGeneration) + windowHost.resumeMount() + } + .onChange(of: Request(text: text, generation: accountGeneration)) { + model.submit(text: text, generation: accountGeneration) + } + .onChange(of: isCurrentPreparation, initial: true) { _, ready in + if ready { preparedPublication?() } + } + .onChange(of: scenePhase) { + if scenePhase == .active { + windowHost.resumeMount() + model.submit(text: text, generation: accountGeneration) + } else { + model.suspend() + windowHost.renderer?.invalidate() + } + } + #if DEBUG + .onChange(of: model.prepared?.presentationKey) { + if let diagnosticPublication { + let attachments = model.prepared?.content.runs.compactMap { $0.textual.attachment } ?? [] + let ink = attachments.compactMap { $0.pngData() }.filter( + EquationDiagnosticView.hasVisibleGlyphs + ).count + diagnosticPublication(attachments.count, ink) + } + } + #endif + .onDisappear { + #if DEBUG + diagnosticAppearance?(false) + let projection = model.generation == accountGeneration ? model.prepared?.presentationKey : nil + #endif + if retainsRendering() { model.suspend() } else { model.cancel() } + #if DEBUG + disappearanceDiagnostic( + projection != nil, projection != nil && projection == model.prepared?.presentationKey) + #endif + windowHost.renderer?.invalidate() + } + } + + private struct Request: Equatable { let text: String; let generation: UUID? } + private var isCurrentPreparation: Bool { + model.generation == accountGeneration && model.prepared?.revision == text + } + private func equationAccessibilityText(_ prepared: PreparedMessage) -> Text? { + // Accessibility must not wait for rasterization or disappear when an + // unsupported equation stays as its original selectable source. + let sources = prepared.equationSources + return sources.isEmpty ? nil : Text(verbatim: sources.joined(separator: "; ")) + } + private var messageFont: Font { + switch font { + case .manrope: MapleTypography.body + case .system: .body + case .serif: .system(.body, design: .serif) + } + } + } + + private struct PreparedMarkup: MarkupParser { + let content: AttributedString + func attributedString(for input: String) throws -> AttributedString { content } + } + + /// Measure bounded cells at their ideal width before the horizontal scroller + /// proposes its viewport width. A max-width-only table permits Grid to squeeze + /// every column into the viewport, even at accessibility text sizes. + private struct MessageTableStyle: StructuredText.TableStyle { + func makeBody(configuration: Configuration) -> some View { + Overflow { + configuration.label + .fixedSize(horizontal: true, vertical: true) + .textual.tableOverlay { layout in + Canvas { context, _ in + for divider in layout.dividers() { + context.fill(Path(divider), with: .color(.secondary.opacity(0.2))) + } + } + } + .padding(1) + } + .textual.tableCellSpacing(horizontal: 1, vertical: 1) + .textual.blockSpacing(.fontScaled(top: 1.6, bottom: 1.6)) + } + } + + private struct MessageTableCellStyle: StructuredText.TableCellStyle { + @ScaledMetric(relativeTo: .body) private var maximumWidth: CGFloat = 280 + func makeBody(configuration: Configuration) -> some View { + BoundedTableCellLayout(maximumWidth: maximumWidth) { + configuration.label + .fontWeight(configuration.row == 0 ? .semibold : .regular) + .textual.lineSpacing(.fontScaled(0.471)) + .textual.padding(.fontScaled(0.588)) + } + } + } + + /// Report the cell's bounded intrinsic width instead of expanding a frame + /// to Grid's column proposal. Grid then retains the Markdown column's own + /// leading, center, or trailing alignment for shorter cell content. + private struct BoundedTableCellLayout: Layout { + let maximumWidth: CGFloat + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + guard let cell = subviews.first else { return .zero } + let width = min(maximumWidth, cell.sizeThatFits(.unspecified).width) + return cell.sizeThatFits(ProposedViewSize(width: width, height: nil)) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + guard let cell = subviews.first else { return } + cell.place( + at: bounds.origin, anchor: .topLeading, + proposal: ProposedViewSize(width: bounds.width, height: bounds.height)) + } + } + + private struct MessageCodeStyle: StructuredText.CodeBlockStyle { + let blocks: [String: PreparedCode] + func makeBody(configuration: Configuration) -> some View { + if let key = configuration.languageHint, let code = blocks[key] { + VStack(alignment: .leading, spacing: 8) { + // Header height follows actual Dynamic Type content. Keeping + // it outside Overflow avoids covering the first code line. + HStack { + Text(code.block.language).font(MapleTypography.metadata).foregroundStyle(.secondary) + Spacer() + MessageCodeCopyButton(source: code.block.source) + .textual.textSelectionInteractionExcluded() + } + Overflow { + InlineText(code.block.source, parser: PreparedMarkup(content: highlighted(code.tokens))) + .textual.textSelection(.enabled) + .font(MapleTypography.code) + .fixedSize(horizontal: true, vertical: false) + } + .scrollIndicators(.visible) + .accessibilityIdentifier("message.code.scroll") + } + .padding(12) + .background(MapleSurface.card, in: RoundedRectangle(cornerRadius: 12)) + } else { + configuration.label + } + } + + private func highlighted(_ tokens: [MessageCodeToken]) -> AttributedString { + var result = AttributedString() + for token in tokens { + var part = AttributedString(token.text) + switch token.kind { + case .plain: break + case .keyword: part.foregroundColor = codeColor(light: (0.45, 0.18, 0.65), dark: (0.80, 0.63, 1.0)) + case .string: part.foregroundColor = codeColor(light: (0.03, 0.39, 0.14), dark: (0.48, 0.87, 0.56)) + case .comment: part.foregroundColor = .secondary + case .number: part.foregroundColor = codeColor(light: (0.53, 0.25, 0.01), dark: (1.0, 0.72, 0.42)) + } + result.append(part) + } + return result + } + + private func codeColor( + light: (CGFloat, CGFloat, CGFloat), dark: (CGFloat, CGFloat, CGFloat) + ) -> Color { + Color( + uiColor: UIColor { traits in + let color = traits.userInterfaceStyle == .dark ? dark : light + return UIColor(red: color.0, green: color.1, blue: color.2, alpha: 1) + }) + } + } + + private struct MessageCodeCopyButton: View { + let source: String + @State private var copied = false + @State private var copyRevision = UUID() + + var body: some View { + Button { + UIPasteboard.general.setItems( + [[UTType.utf8PlainText.identifier: source]], + options: [.localOnly: true, .expirationDate: Date().addingTimeInterval(120)]) + copied = true; copyRevision = UUID() + } label: { + Image(systemName: copied ? "checkmark" : "document.on.document") + .frame(minWidth: 44, minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(MapleStrings.text(copied ? "Copied" : "Copy code")) + .task(id: copyRevision) { + guard copied else { return } + try? await Task.sleep(nanoseconds: 2_000_000_000) + if !Task.isCancelled { copied = false } + } + } + } + + private struct NoMessageAttachments: AttachmentLoader { + struct Rejected: Error {} + func attachment(for url: URL, text: String, environment: ColorEnvironmentValues) async throws -> NeverAttachment + { + throw Rejected() + } + } + + private struct NeverAttachment: Attachment { + var description: String { "" } + var body: some View { EmptyView() } + func sizeThatFits(_ proposal: ProposedViewSize, in environment: TextEnvironmentValues) -> CGSize { .zero } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Rendering/PresentationCoverFixtureView.swift b/apps/maple-ios/Sources/MapleAppUI/Rendering/PresentationCoverFixtureView.swift new file mode 100644 index 000000000..fe45d4264 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Rendering/PresentationCoverFixtureView.swift @@ -0,0 +1,280 @@ +#if os(iOS) && DEBUG + import Foundation + import MapleAppCore + import MapleSDK + import Observation + import SwiftUI + import UIKit + + /// Explicit debug fixture. All messages, image pixels, storage, and playback + /// are synthetic. The viewer, presentation lease, runtime completion gate, + /// and listening state machine are the shipping implementations. + struct PresentationCoverFixtureView: View { + @State private var model = PresentationCoverFixtureModel() + @State private var preview: ChatImageAttachment? + @State private var compactLease = CompactMenuLease() + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.layoutDirection) private var layoutDirection + + private var navigationProof: Bool { + ProcessInfo.processInfo.arguments.contains("--maple-presentation-cover-navigation-proof") + } + + var body: some View { + Group { + if navigationProof { + nativeNavigation + } else { + coverContent + } + } + } + + private var coverContent: some View { + VStack(spacing: 20) { + Text("Image cover completion eligibility").font(.headline) + Text(model.summary).accessibilityIdentifier("cover.summary") + Button("Open synthetic image") { preview = model.image } + .accessibilityIdentifier("cover.open") + Button("Complete visible reply") { model.send() } + .accessibilityIdentifier("cover.visible-completion") + .disabled(model.runtime.hasUnresolvedRun || model.completions != 1 || !model.blocks.isEmpty) + } + .padding() + .chatFullScreenCover(item: $preview) { image in + ImageZoomView(attachment: image) + .safeAreaInset(edge: .bottom) { + VStack { + Text(model.summary).accessibilityIdentifier("cover.inside-summary") + Button("Complete covered reply") { model.send() } + .accessibilityIdentifier("cover.covered-completion") + .disabled(model.runtime.hasUnresolvedRun || model.completions != 0) + }.padding().background(.regularMaterial) + } + } + .environment(\.chatPresentationChanged) { id, active in model.presentationChanged(id: id, active: active) } + } + + /// The same viewer and lease run inside the shipping retained native + /// navigation. This flag adds no alternate dismissal or recovery path. + private var nativeNavigation: some View { + let scope = model.runtime.scope, owner = model.runtime.snapshot.storageOwner + return CompactMenuNavigation( + scope: scope, storageOwner: owner, + requestedMenu: model.menuPresented, reduceMotion: reduceMotion, lease: compactLease, + library: NavigationStack { + Text("Synthetic retained Library") + .accessibilityIdentifier("cover.library") + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(MapleSurface.sidebar) + .toolbarVisibility(.hidden, for: .navigationBar) + .compactMenuRootBar( + role: .library, title: "Synthetic retained Library", + trailing: AnyView(closeMenu(scope: scope, owner: owner))) + } + .environment(\.layoutDirection, layoutDirection), + chat: + coverContent + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(MapleSurface.canvas) + .compactMenuRootBar(role: .chat, title: "Synthetic cover chat") + .environment(\.layoutDirection, layoutDirection) + .modifier( + CompactMenuChatAccessibility( + lease: compactLease, scope: scope, storageOwner: owner, + isCurrent: { [weak model] in + model?.isCurrent(scope: scope, storageOwner: owner) ?? false + })), + isCurrent: { [weak model] in model?.isCurrent(scope: scope) ?? false }, + presentationBlocked: { [weak model] in !(model?.blocks.isEmpty ?? false) }, + requestMenu: { [weak model] in model?.setMenu($0, scope: scope) }, + blockPresentation: { [weak model] id, active in + guard let model, model.isCurrent(scope: scope) else { return } + model.presentationChanged(id: id, active: active) + }, + chatShown: { _ in } + ) + .id(scope) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea(.container) + } + + private func closeMenu(scope: AccountScope, owner: UUID) -> some View { + Button { + guard model.isCurrent(scope: scope, storageOwner: owner) else { return } + model.setMenu(false, scope: scope) + } label: { + Label("Close menu", systemImage: "xmark") + .labelStyle(.iconOnly) + .frame(minWidth: 44, minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("cover.library-close") + .disabled(!compactLease.menuVisible) + } + } + + @MainActor @Observable private final class PresentationCoverFixtureModel { + let runtime: ChatRuntime + let listening: ReplyListeningSession + let player: CoverFixturePlayer + let image: ChatImageAttachment + var blocks = Set() + var completions = 0 + var blockedCompletions = 0 + var hapticEligible = 0 + var failure = false + private(set) var menuPresented = false + + init() { + let scope = AccountScope(apiOrigin: "https://synthetic.invalid", principalID: "cover-fixture") + let player = CoverFixturePlayer() + self.player = player + listening = ReplyListeningSession(scope: scope, preparer: CoverFixturePreparer(), player: player) + runtime = ChatRuntime( + scope: scope, service: CoverFixtureService(), persistence: CoverFixtureStore(), + conversationID: "cover-chat") + // Encoded locally, never downloaded or written to a file. + let pixels = UIGraphicsImageRenderer(size: CGSize(width: 80, height: 80)).pngData { context in + UIColor.systemTeal.setFill(); context.fill(CGRect(x: 0, y: 0, width: 80, height: 80)) + } + image = ChatImageAttachment(data: pixels, contentType: "image/png") + runtime.isVisible = true + listening.setVisibility(chatID: "cover-chat", foreground: true) + listening.setAutoplay(true, chatID: "cover-chat") + runtime.onCompletion = { [weak self] completion in + guard let self else { return } + completions += 1 + if !blocks.isEmpty && !completion.wasVisible && completion.wasForeground { + blockedCompletions += 1 + } + listening.completed(completion) + } + // This is precisely the production haptic admission callback. Count + // invocation rather than emitting a physical haptic in the fixture. + runtime.onSuccessfulVisibleCompletion = { [weak self] _ in self?.hapticEligible += 1 } + } + + var summary: String { + "Leases \(blocks.count); completions \(completions); covered \(blockedCompletions); plays \(player.plays); haptics \(hapticEligible); pending \(listening.pendingCount); failed \(failure ? 1 : 0)" + } + + func presentationChanged(id: UUID, active: Bool) { + if active { blocks.insert(id) } else { blocks.remove(id) } + // Same visibility inputs as AppEnvironment.updatePresentationVisibility. + // The fixture deliberately does not claim to exercise account/root wiring. + updateVisibility() + } + + func isCurrent(scope: AccountScope, storageOwner: UUID? = nil) -> Bool { + !runtime.isRetired && runtime.scope == scope + && (storageOwner == nil || runtime.snapshot.storageOwner == storageOwner) + } + + func setMenu(_ presented: Bool, scope: AccountScope) { + guard isCurrent(scope: scope) else { return } + menuPresented = presented + updateVisibility() + } + + private func updateVisibility() { + let visible = !runtime.isRetired && !menuPresented && blocks.isEmpty + runtime.isVisible = visible + listening.setVisibility(chatID: visible ? "cover-chat" : nil, foreground: true) + } + + func send() { + runtime.draftText = "Synthetic cover eligibility turn" + if !runtime.send() { failure = true } + } + } + + private actor CoverFixtureStore: ChatPersistence { + private var snapshot: ChatSnapshot? + func load(chatID: String, scope: AccountScope) async throws -> ChatSnapshot? { snapshot } + func loadAll(scope: AccountScope) async throws -> [ChatSnapshot] { snapshot.map { [$0] } ?? [] } + func save(_ snapshot: ChatSnapshot, scope: AccountScope) async throws { self.snapshot = snapshot } + func remove(chatID: String, scope: AccountScope) async throws { snapshot = nil } + func rekey(from: String, to snapshot: ChatSnapshot, scope: AccountScope) async throws { + self.snapshot = snapshot + } + } + + private actor CoverFixtureService: ChatServing { + private var responseNumber = 0 + func createConversation(metadata: [String: JSONValue]?, projectId: String?, pinned: Bool?) async throws + -> Conversation + { + Conversation(id: "cover-chat", object: "conversation", createdAt: 0, pinned: false, lastActivityAt: 0) + } + func listConversations(_ parameters: ConversationsListParameters) async throws -> APIPage { + APIPage(data: []) + } + func createResponse(_ request: ResponsesCreateRequest, maximumInferenceSends: Int) async throws + -> AsyncThrowingStream + { + responseNumber += 1 + let id = "cover-response-\(responseNumber)" + let created = ResearchResponse( + id: id, object: "response", createdAt: 0, status: .inProgress, model: "auto:quick") + let completed = ResearchResponse( + id: id, object: "response", createdAt: 0, status: .completed, model: "auto:quick", + output: [ + ConversationItem( + id: "cover-assistant-\(responseNumber)", responseId: id, type: "message", status: "completed", + role: "assistant", + content: [MessageContent(type: "output_text", text: "Synthetic readable completion")]) + ]) + let events: [ResponseStreamEvent] = [ + .created(try lifecycle(created)), .completed(try lifecycle(completed)), + ] + return AsyncThrowingStream { continuation in + for event in events { continuation.yield(event) } + continuation.finish() + } + } + private func lifecycle(_ response: ResearchResponse) throws -> ResponseLifecycleEvent { + struct Wrapper: Encodable { let response: ResearchResponse } + let encoder = JSONEncoder(); encoder.keyEncodingStrategy = .convertToSnakeCase + let decoder = JSONDecoder(); decoder.keyDecodingStrategy = .convertFromSnakeCase + return try decoder.decode(ResponseLifecycleEvent.self, from: encoder.encode(Wrapper(response: response))) + } + func retrieveResponse(id: String) async throws -> ResearchResponse { throw ChatIssue.uncertainResponse } + func cancelResponse(id: String) async throws -> ResearchResponse { throw ChatIssue.cancellationUnavailable } + func getConversationItem(conversationId: String, itemId: String) async throws -> ConversationItem { + throw ResearchAPIError.http(status: 404, reason: .notFound) + } + func listConversationItems(conversationId: String, parameters: ListParameters) async throws -> APIPage< + ConversationItem + > { APIPage(data: []) } + } + + private actor CoverFixturePreparer: SpeechPreparing { + func prepare(_ reply: SpeechReply, voice: MapleVoice, owner: SpeechOwner) async throws -> PreparedSpeech { + PreparedSpeech( + owner: owner, responseID: reply.responseID, + chunks: [ + SpeechChunk( + file: StoredSpeechFile( + id: UUID(), url: URL(fileURLWithPath: "/synthetic/never-read.wav"), byteCount: 40), + duration: 1) + ]) + } + func discard(_ speech: PreparedSpeech) async throws {} + func retire(_ owner: SpeechOwner) async throws {} + } + + @MainActor @Observable private final class CoverFixturePlayer: SpeechPlaying { + var onEvent: (@MainActor (SpeechPlayerEvent) -> Void)? + var position: TimeInterval = 0 + var duration: TimeInterval = 0 + var plays = 0 + func load(_ speech: PreparedSpeech, playbackID: UUID) async throws { duration = speech.duration } + func play() throws { plays += 1 } + func pause() {} + func stop() { position = 0; duration = 0 } + func seek(to position: TimeInterval) { self.position = position } + func setSpeed(_ speed: MaplePlaybackSpeed) {} + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Rendering/RendererAcceptanceCorpus.swift b/apps/maple-ios/Sources/MapleAppUI/Rendering/RendererAcceptanceCorpus.swift new file mode 100644 index 000000000..e2de2e809 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Rendering/RendererAcceptanceCorpus.swift @@ -0,0 +1,55 @@ +#if DEBUG + import Foundation + + /// Shared synthetic source for native runtime fidelity and parser producer tests. + enum RendererAcceptanceCorpus { + static let markdown = #""" + Soft first + Soft second + + Hard first + Hard second + + # GFM heading one + ## GFM heading two + ### GFM heading three + + **Bold**, *italic*, ~~struck~~ and `inline code`. + + - unordered one + - nested unordered child + - [ ] Pending task marker + - [x] Completed task marker + + 3. ordered third + 4. ordered fourth + + > Quoted **rich text**. + > Quote final. + + Indented ordinary prose in the Research baseline. + + Raw HTML stays untrusted data. + + Bare link https://example.test/autolink and [reference link][destination]. + + [destination]: https://example.test/reference + + ![Preserved remote image alt](https://example.test/never.png) + + ```swift + let synthetic = "copy code" // preserved + print(synthetic) + ``` + + العربية עברית 中文 👩🏽‍💻 é. + + \[\begin{bmatrix}a & b\\c & d\end{bmatrix}\] + + \[f(x)=\begin{cases}x^2 & x\ge0\\-x & x<0\end{cases}\] + + Invalid original source \(\unsupportedcommand{x}\). + Incomplete original source \(\frac{1}{ + """# + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Rendering/RendererFixtureView.swift b/apps/maple-ios/Sources/MapleAppUI/Rendering/RendererFixtureView.swift new file mode 100644 index 000000000..6486ccc1f --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Rendering/RendererFixtureView.swift @@ -0,0 +1,433 @@ +#if os(iOS) && DEBUG + import SwiftUI + import UIKit + import Accessibility + import Textual + import UniformTypeIdentifiers + + /// Synthetic fixtures only. Available solely through an explicit debug launch argument. + struct RendererFixtureView: View { + @State private var suffix = "" + @State private var generation = UUID() + @State private var pasted = "" + @State private var links = 0 + @State private var dark = false + @State private var revision = 0 + @State private var accessibilityChecked = false + @State private var accessibilityCounts = "AX not checked" + @State private var selectionStatus = "Selection not checked" + var body: some View { + if ProcessInfo.processInfo.arguments.contains("--maple-presentation-cover-fixture") { + PresentationCoverFixtureView() + } else if ProcessInfo.processInfo.arguments.contains("--maple-renderer-lifecycle") { + MessageEquationLifecycleFixtureView() + } else if ProcessInfo.processInfo.arguments.contains("--maple-renderer-corpus") { + RendererCorpusFixtureView() + } else if ProcessInfo.processInfo.arguments.contains("--maple-renderer-layout") { + RendererLayoutFixtureView() + } else { + standardContent + } + } + + private var standardContent: some View { + VStack(alignment: .leading) { + HStack { + Button("Append after delay") { + Task { @MainActor in + var before: FixtureSelection? + for _ in 0..<100 { + if let current = Self.selectedFixtureText() { before = current } + try? await Task.sleep(for: .milliseconds(100)) + } + suffix += " Appended synthetic stream text." + revision += 1 + try? await Task.sleep(for: .seconds(1)) + let after = Self.selectedFixtureText() + selectionStatus = + before != nil && before == after + ? "Selection preserved" : "Selection changed" + } + }.accessibilityIdentifier("renderer.append") + Button("Revoke") { + generation = UUID(); suffix = " Revoked generation." + } + .accessibilityIdentifier("renderer.revoke") + Toggle("Dark", isOn: $dark).fixedSize() + } + Text("Opened links: \(links)").accessibilityIdentifier("renderer.links") + Text("Revision \(revision)").accessibilityIdentifier("renderer.revision") + Text(selectionStatus).font(.caption).accessibilityIdentifier("renderer.selection-status") + HStack { + Button("Check equation accessibility") { + let result = Self.nativeEquationContent() + accessibilityChecked = result.found + accessibilityCounts = result.counts + }.accessibilityIdentifier("renderer.ax-check") + Text(accessibilityChecked ? "Equation AX present" : "Equation AX not checked") + .accessibilityIdentifier("renderer.ax-status") + }.font(.caption) + Text(accessibilityCounts).font(.caption).accessibilityIdentifier("renderer.ax-counts") + TextField("Paste selection", text: $pasted, axis: .vertical) + .accessibilityIdentifier("renderer.paste").textFieldStyle(.roundedBorder) + ScrollView { + EquationDiagnosticView() + MessageView( + text: Self.fixture + suffix, theme: dark ? .dark : .light, + accountGeneration: generation + ) { _ in links += 1 } + .accessibilityIdentifier("renderer.message") + .padding() + } + }.padding() + } + + // DEBUG fixture inspects only its own rendered native accessibility tree. + // Uses public container/provider APIs and returns no content to the test. + @MainActor private static func nativeEquationContent() -> (found: Bool, counts: String) { + var pending: [NSObject] = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + .flatMap(\.windows).map { $0 as NSObject } + var seen = Set() + var providers = 0 + var contents = 0 + var matches = 0 + while let item = pending.popLast(), seen.count < 1024 { + guard seen.insert(ObjectIdentifier(item)).inserted else { continue } + var nativeContent: [AXCustomContent] = [] + if let provider = item as? any AXCustomContentProvider { + providers += 1 + let block = provider.accessibilityCustomContentBlock ?? nil + nativeContent = block?() ?? provider.accessibilityCustomContent ?? [] + } else { + // Some framework-owned accessibility nodes implement this + // public Objective-C accessor without declaring the protocol. + let selector = NSSelectorFromString("accessibilityCustomContent") + if item.responds(to: selector) { + providers += 1 + nativeContent = item.perform(selector)?.takeUnretainedValue() as? [AXCustomContent] ?? [] + } + } + for content in nativeContent { + contents += 1 + matches = max( + matches, EquationDiagnosticView.formulas.filter { content.value.contains($0) }.count) + if content.importance == .high, + EquationDiagnosticView.formulas.allSatisfy({ content.value.contains($0) }) + { + return ( + true, + "AX nodes \(seen.count), providers \(providers), contents \(contents), matches \(matches)" + ) + } + } + var children = (item.accessibilityElements ?? []).compactMap { $0 as? NSObject } + let count = item.accessibilityElementCount() + if children.isEmpty, (1...1024).contains(count) { + children = (0.. FixtureSelection? { + var pending = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.flatMap(\.windows) + .map { $0 as UIView } + var visited = 0 + while let view = pending.popLast(), visited < 1024 { + visited += 1 + if view.isFirstResponder, let input = view as? any UITextInput, + let range = input.selectedTextRange, !range.isEmpty, + let text = input.text(in: range), !text.isEmpty + { + return FixtureSelection( + text: text, start: input.offset(from: input.beginningOfDocument, to: range.start), + length: input.offset(from: range.start, to: range.end)) + } + pending.append(contentsOf: view.subviews.prefix(max(0, 1024 - visited - pending.count))) + } + return nil + } + + static let fixture = #""" + Start alpha **bold beta** and *italic gamma*, [delta link](https://example.test) and end epsilon. + + Arabic العربية Hebrew עברית CJK 中文 emoji 👩🏽‍💻 and combining é. + + ```swift + let synthetic = "copy code" // preserved + print(synthetic) + ``` + + | Name | Formula | Detail | + | :--- | ---: | :---: | + | row alpha | $x^2$ | beta cell | + | row gamma | $\frac{1}{2}$ | delta cell | + + Final omega after code and table. + + Control $\frac{1}{2}+\sqrt{x^2+1}$. + Operator $\operatorname{argmax}_{x} f(x)$. + Delimiters $\Bigl(\frac{a}{b}\Bigr)$. + Boxed $\boxed{E=mc^2}$. + Macro $\newcommand{\RR}{\mathbb{R}}\RR^2$. + + \[E=mc^2\tag{1}\] + + [Blocked script](javascript:alert%281%29) ![Remote image](https://example.test/never.png) + """# + } + private struct RendererCorpusFixtureView: View { + @State private var copyStatus = "Not run" + @State private var copyDiagnostics = "Not inspected" + @State private var copiedScalars = "Not copied" + @State private var multilingualStatus = "Not run" + @State private var representationStatus = "Not inspected" + @State private var linesReady = false + @State private var multilingualReady = false + @State private var readiness = "Waiting for native selection spans" + var body: some View { + VStack(alignment: .leading) { + HStack { + Button("Copy synthetic line probe") { + guard + let result = Self.copySpan( + from: "Soft first", through: "Hard second", diagnostics: ©Diagnostics) + else { copyStatus = "Native copy unavailable"; return } + copiedScalars = Self.scalars(result.copied) + copyStatus = + "Soft newline \(result.copied.contains("Soft first\nSoft second") ? 1 : 0); " + + "hard newline \(result.copied.contains("Hard first\nHard second") ? 1 : 0)" + let expected = Data("Soft first\nSoft second\n\nHard first\nHard second".utf8) + representationStatus = + result.rawUTF8 == expected && Data(result.copied.utf8) == expected + ? "Canonical LF and raw UTF8 preserved" : "Copy representation differs" + }.accessibilityIdentifier("renderer.copy-lines").disabled(!linesReady) + Button("Copy multilingual span") { + guard + let result = Self.copySpan( + from: Self.multilingual, through: Self.multilingual, diagnostics: ©Diagnostics) + else { multilingualStatus = "Native copy unavailable"; return } + copiedScalars = Self.scalars(result.copied) + multilingualStatus = + Array(result.selected.utf8) == Array(Self.multilingual.utf8) + && Array(result.copied.utf8) == Array(Self.multilingual.utf8) + ? "Multilingual bytes preserved" : "Multilingual bytes changed" + representationStatus = + result.rawUTF8 == Data(Self.multilingual.utf8) + ? "Raw UTF8 preserved" : "Raw UTF8 changed" + }.accessibilityIdentifier("renderer.copy-multilingual").disabled(!multilingualReady) + } + Text(readiness).font(.caption2).lineLimit(3, reservesSpace: true) + .accessibilityLabel(readiness).accessibilityIdentifier("renderer.copy-readiness") + Text(copyStatus).accessibilityIdentifier("renderer.line-status") + Text(multilingualStatus).accessibilityIdentifier("renderer.multilingual-status") + Text(representationStatus).font(.caption).accessibilityIdentifier("renderer.representation-status") + Text(copyDiagnostics).font(.caption2).lineLimit(8) + .accessibilityLabel(copyDiagnostics).accessibilityIdentifier("renderer.line-diagnostics") + Text(copiedScalars).font(.caption2).accessibilityIdentifier("renderer.copy-scalars") + ScrollView { + MessageView(text: Self.corpus) { _ in }.padding() + }.accessibilityIdentifier("renderer.corpus-scroll") + }.padding() + .task { await observeNativeSpans() } + } + + @MainActor private func observeNativeSpans() async { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(15)) + while !Task.isCancelled { + let lines = Self.findSpan(from: "Soft first", through: "Hard second") + let multilingual = Self.findSpan(from: Self.multilingual, through: Self.multilingual) + linesReady = lines.target != nil + multilingualReady = multilingual.target != nil + readiness = + "Lines \(linesReady ? "ready" : "waiting"); multilingual \(multilingualReady ? "ready" : "waiting"); " + + "lines {\(lines.metadata)}; multilingual {\(multilingual.metadata)}" + if linesReady && multilingualReady { return } + guard clock.now < deadline else { + readiness = "Native span deadline reached; " + readiness + return + } + // Poll only the concrete public input condition, never replay Copy. + do { try await Task.sleep(for: .milliseconds(100)) } catch { return } + } + } + + private static let multilingual = "العربية עברית 中文 👩🏽‍💻 é." + private static func scalars(_ text: String) -> String { + text.unicodeScalars.prefix(128).map { String($0.value, radix: 16) }.joined(separator: ",") + } + + private static func rawText(_ value: Any?) -> String { + if let text = value as? String { + return "String:" + Self.scalars(text) + } + if let data = value as? Data { + guard data.count <= 8192 else { return "Data exceeds synthetic bound" } + guard let text = String(data: data, encoding: .utf8) else { return "Data is not UTF8" } + return "Data:" + Self.scalars(text) + } + return value == nil ? "absent" : "other representation" + } + + // DEBUG-only exact synthetic span. Calls the rendered native responder's + // Copy action; neither explicit whole-message Copy nor a replacement exporter. + @MainActor private static func copySpan( + from first: String, through last: String, diagnostics: inout String + ) -> (selected: String, copied: String, rawUTF8: Data?)? { + let found = findSpan(from: first, through: last) + guard let target = found.target else { + diagnostics = "Synthetic input unavailable; " + found.metadata + return nil + } + let view = target.view + let input = target.input + guard let a = input.position(from: input.beginningOfDocument, offset: target.lower), + let b = input.position(from: input.beginningOfDocument, offset: target.upper), + let range = input.textRange(from: a, to: b) + else { diagnostics = "Range unavailable; " + found.metadata; return nil } + _ = view.becomeFirstResponder() + input.selectedTextRange = range + let selected = input.selectedTextRange.flatMap { input.text(in: $0) } ?? "" + var nativeRuns: [String] = [] + if let native = input.attributedText?(in: range), + let bridged = try? AttributedString(native, including: \.textual) + { + for run in bridged.runs.prefix(64) { + let text = String(bridged[run.range].characters) + let inline = String(run.inlinePresentationIntent?.rawValue ?? 0) + let paragraph = String(String(describing: run.presentationIntent).prefix(128)) + nativeRuns.append(Self.scalars(text) + ":i=" + inline + ":p=" + paragraph) + } + } + let before = UIPasteboard.general.changeCount + view.copy(nil) + guard UIPasteboard.general.changeCount > before else { + diagnostics = "Selected \(selected.utf16.count); copy did not change clipboard" + return nil + } + let copied = UIPasteboard.general.string ?? "" + let retained = input.selectedTextRange.flatMap { input.text(in: $0) } ?? "" + let item = UIPasteboard.general.items.first ?? [:] + let advertised = item.keys.sorted().prefix(8).joined(separator: ",") + let plain = Self.rawText(item[UTType.plainText.identifier]) + let utf8 = Self.rawText(item[UTType.utf8PlainText.identifier]) + let rawUTF8 = UIPasteboard.general.data(forPasteboardType: UTType.utf8PlainText.identifier) + diagnostics = + "Selected \(selected.utf16.count); LF \(selected.filter { $0 == "\n" }.count); " + + "copied \(copied.utf16.count); LF \(copied.filter { $0 == "\n" }.count); " + + "retained \(retained == selected && !selected.isEmpty ? 1 : 0); " + + "responder \(String(reflecting: type(of: view))); " + + "selected scalars \(Self.scalars(selected)); advertised \(advertised); " + + "raw plain \(plain); raw utf8 \(utf8); native runs \(nativeRuns.joined(separator: "|"))" + return (selected, copied, rawUTF8) + } + + @MainActor private struct NativeCopyTarget { + let view: UIView + let input: any UITextInput + let lower: Int + let upper: Int + } + + // Nonmutating bounded readiness: no first-responder, selection or clipboard action. + @MainActor private static func findSpan(from first: String, through last: String) + -> (target: NativeCopyTarget?, metadata: String) + { + let windows = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + .flatMap(\.windows).map { $0 as UIView } + var pending = Array(windows.prefix(1024)) + var visited = 0 + var omitted = max(0, windows.count - 1024) + var inputCount = 0 + var documentCount = 0 + var firstCount = 0 + var lastCount = 0 + var samples: [String] = [] + func metadata() -> String { + "visited=\(visited), pending=\(pending.count), truncated=\(omitted > 0 || (visited >= 1024 && !pending.isEmpty)), " + + "inputs=\(inputCount), documents=\(documentCount), first=\(firstCount), last=\(lastCount), " + + "samples=\(samples.joined(separator: "|"))" + } + while visited < 1024, let view = pending.popLast() { + visited += 1 + if let input = view as? any UITextInput { + inputCount += 1 + if let document = input.textRange(from: input.beginningOfDocument, to: input.endOfDocument), + let full = input.text(in: document) + { + documentCount += 1 + let start = full.range(of: first) + let end = full.range(of: last) + if start != nil { firstCount += 1 } + if end != nil { lastCount += 1 } + if samples.count < 8 { + samples.append("len:\(full.utf16.count),first:\(start != nil),last:\(end != nil)") + } + if let start, let end, start.lowerBound < end.upperBound { + return ( + NativeCopyTarget( + view: view, input: input, lower: full[.. some View { + let scope = model.runtime.scope, owner = model.runtime.snapshot.storageOwner + return CompactMenuNavigation( + scope: scope, storageOwner: owner, + requestedMenu: model.projectionMenuPresented, reduceMotion: reduceMotion, lease: compactLease, + library: NavigationStack { + List { + Text("Synthetic retained Library") + Button("Release older") { Task { await model.releaseOlder() } } + .accessibilityIdentifier("shipping-pagination.release-older") + .disabled(!model.runtime.isLoadingOlder || !compactLease.menuVisible) + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .background(MapleSurface.sidebar) + .navigationBarTitleDisplayMode(.inline) + .toolbarVisibility(.hidden, for: .navigationBar) + .compactMenuRootBar( + role: .library, title: "Synthetic retained Library", + principal: AnyView(paginationNavigationDiagnostics(model)), + trailing: AnyView(paginationCloseControl(model))) + } + .environment(\.layoutDirection, layoutDirection) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(MapleSurface.sidebar) + .background(ShippingPaginationViewportProbe()) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("shipping-pagination.library"), + chat: ChatDetail( + environment: model.environment, runtime: model.runtime, + account: model.account, preferences: model.preferences, + fixtureProjectionIsCurrent: { [weak model] scope, owner in + model?.projectionIsCurrent(scope: scope, storageOwner: owner) ?? false + }, fixtureOptions: model.optionsContext, + fixtureScrollStateChanged: { [weak model] state in model?.scrollState = state } + ) + .id(owner) + .background(MapleSurface.canvas) + .compactMenuRootBar( + role: .chat, title: "Synthetic retained chat", + backIdentifier: "shipping-pagination.open", + principal: AnyView(paginationNavigationDiagnostics(model)) + ) + .environment(\.layoutDirection, layoutDirection) + .environment(\.compactMenuChatState) { [weak compactLease] scope, owner in + compactLease?.state(scope: scope, storageOwner: owner) ?? CompactMenuChatState() + } + .background(MapleSurface.canvas) + .modifier( + CompactMenuChatAccessibility( + lease: compactLease, scope: scope, storageOwner: owner, + isCurrent: { [weak model] in + model?.projectionIsCurrent(scope: scope, storageOwner: owner) ?? false + })), + // Admit same-account updates while initial loading replaces the + // owner; the lease and child still fence the exact projection. + isCurrent: { [weak model] in + model?.projectionIsCurrent(scope: scope) ?? false + }, + presentationBlocked: { false }, + requestMenu: { [weak model] in model?.projectionMenuPresented = $0 }, + blockPresentation: { _, _ in }, + chatShown: { reason in + model.paginationChatShownCalls += 1 + // The controller retains this callback across initial + // loading, which can replace the snapshot's storage owner. + // Read the controller lease's acknowledged owner now. + guard compactLease.scope == scope, let shownOwner = compactLease.storageOwner, + model.projectionIsCurrent(scope: scope, storageOwner: shownOwner), + !model.projectionMenuPresented, + compactLease.state(scope: scope, storageOwner: shownOwner).returnRevision > 0, + !compactLease.state(scope: scope, storageOwner: shownOwner).occluded + else { return } + model.paginationChatShownAdmissions += 1 + // The isolated environment owns no signed-in chat registry. + // Mirror production's acknowledged visibility publication + // for this exact retained synthetic runtime. + model.runtime.isVisible = true + if reason == .navigation { paginationMenuFocused = true } + } + ) + .id(scope) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea(.container) + .onChange(of: model.projectionMenuPresented) { _, presented in + if presented { paginationMenuFocused = false } + } + .onChange(of: compactLease.state(scope: scope, storageOwner: owner), initial: true) { _, state in + guard compactLease.scope == scope, compactLease.storageOwner == owner, + model.projectionIsCurrent(scope: scope, storageOwner: owner) + else { return } + model.paginationLeasePublications += 1 + model.runtime.isVisible = state.returnRevision > 0 && !state.occluded + paginationCloseFocused = compactLease.menuVisible + } + .onChange(of: model.runtime.isVisible) { _, _ in + model.paginationVisibilityChanges += 1 + } + } + + private func paginationCloseControl(_ model: ShippingViewportFixtureModel) -> some View { + let scope = model.runtime.scope, owner = model.runtime.snapshot.storageOwner + return Button { + guard model.projectionIsCurrent(scope: scope, storageOwner: owner) else { return } + model.projectionMenuPresented = false + } label: { + Label("Close menu", systemImage: "xmark") + .labelStyle(.iconOnly) + .frame(minWidth: 44, minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("shipping-pagination.close") + .accessibilityFocused($paginationCloseFocused) + } + + private func paginationNavigationDiagnostics(_ model: ShippingViewportFixtureModel) -> some View { + ShippingPaginationNavigationDiagnostics(model: model, lease: compactLease) + } + + private func compactViewport(_ model: ShippingViewportFixtureModel) -> some View { + let selected = model.selectedProjection + let scope = model.account.scope + return CompactMenuNavigation( + scope: scope, storageOwner: selected.snapshot.storageOwner, + requestedMenu: model.projectionMenuPresented, reduceMotion: reduceMotion, lease: compactLease, + library: compactProjectionLibrary(model) + .compactMenuRootBar(role: .library, title: "Synthetic retained Library"), + chat: ChatDetail( + environment: model.environment, runtime: selected, + account: model.account, preferences: model.preferences, + fixtureProjectionIsCurrent: { [weak model] scope, owner in + model?.projectionIsCurrent(scope: scope, storageOwner: owner) ?? false + }, fixtureOptions: model.optionsContext + ) + .id(selected.snapshot.storageOwner) + .compactMenuRootBar(role: .chat, title: "Synthetic cached chat", principal: AnyView(MaplePrivacyLock())) + .environment(\.compactMenuChatState) { [weak compactLease] scope, owner in + compactLease?.state(scope: scope, storageOwner: owner) ?? CompactMenuChatState() + } + .modifier( + CompactMenuChatAccessibility( + lease: compactLease, scope: scope, storageOwner: selected.snapshot.storageOwner, + isCurrent: { [weak model] in model?.projectionIsCurrent(scope: scope) ?? false })), + isCurrent: { [weak model] in model?.projectionIsCurrent(scope: scope) ?? false }, + presentationBlocked: { false }, + requestMenu: { [weak model] in model?.projectionMenuPresented = $0 }, + blockPresentation: { _, _ in }, + chatShown: { _ in model.completeColdHistoryNavigation(scope: scope) } + ) + .id(scope) + .safeAreaInset(edge: .top, spacing: 0) { + Group { + if model.coldHistoryProof { + VStack(spacing: 4) { + Button("Release first history") { Task { await model.releaseColdHistory() } } + .accessibilityIdentifier("shipping-cold-history.release") + .disabled(!model.draftLoadHeld || !model.coldHistoryNavigationSettled) + Text(model.coldHistoryState).font(.caption2) + .accessibilityIdentifier("shipping-cold-history.state") + } + } else { + VStack(spacing: 4) { + HStack { + Button("Select cached A") { model.selectProjection(newChat: true) } + .accessibilityIdentifier("shipping-projection.select-a") + .disabled(!model.ready || model.projectionIsNewChat) + Button("Select cached B") { model.selectProjection(newChat: false) } + .accessibilityIdentifier("shipping-projection.select-b") + .disabled(!model.ready || !model.projectionIsNewChat) + }.font(.caption) + Text(model.summary).font(.caption2) + .accessibilityIdentifier("shipping-viewport.summary") + Text("Synthetic cached owners").font(.caption2) + .accessibilityIdentifier("shipping-projection.ownership") + .accessibilityValue(model.projectionOwnership) + } + } + } + .frame(maxWidth: .infinity).frame(height: 80).background(.bar) + } + } + + @ViewBuilder private func compactProjectionLibrary(_ model: ShippingViewportFixtureModel) -> some View { + if model.coldHistoryProof { + Button("Open existing synthetic chat") { Task { await model.selectColdHistory() } } + .accessibilityIdentifier("shipping-cold-history.select") + .disabled(!model.ready || !model.projectionIsNewChat || !compactLease.menuVisible) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + Text("Synthetic retained Library").frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + @ViewBuilder private func controls(_ model: ShippingViewportFixtureModel) -> some View { + if model.streamWidthProof { + VStack(spacing: 4) { + HStack { + Button("Start run") { model.startRun() } + .accessibilityIdentifier("shipping-viewport.start") + .disabled(!model.ready || model.started) + Button("Advance reply") { Task { await model.advanceWidthReply() } } + .accessibilityIdentifier("shipping-stream-width.advance") + .disabled( + model.runtime.phase != .streaming || model.advancingWidthReply + || model.widthReplyStage >= 3) + Button("Finish") { Task { await model.finish() } } + .accessibilityIdentifier("shipping-viewport.finish") + .disabled( + model.widthReplyStage != 3 || model.advancingWidthReply + || !model.runtime.hasUnresolvedRun) + }.font(.caption) + Text(model.summary).font(.caption2).lineLimit(1).minimumScaleFactor(0.6) + .accessibilityIdentifier("shipping-viewport.summary") + Text("Stage \(model.widthReplyStage); advancing \(model.advancingWidthReply ? 1 : 0)").font( + .caption2 + ) + .accessibilityIdentifier("shipping-stream-width.stage") + Text("Synthetic tool horizontal geometry").font(.caption2) + .accessibilityIdentifier("shipping-stream-width.geometry") + .accessibilityValue(model.widthToolGeometryJSON) + Text("Synthetic horizontal viewport metrics").font(.caption2) + .accessibilityIdentifier("shipping-stream-width.scroll-metrics") + .accessibilityValue(model.horizontalScrollMetricsJSON) + } + .frame(maxWidth: .infinity).frame(height: 96) + } else if model.historyRenderingProof { + VStack(spacing: 4) { + Text(model.summary).font(.caption2) + .accessibilityIdentifier("shipping-viewport.summary") + Text("Synthetic completed render lifecycles").font(.caption2) + .accessibilityIdentifier("shipping-history.render-lifecycles") + .accessibilityValue(model.renderLifecyclesJSON) + } + .frame(maxWidth: .infinity).frame(height: 60) + } else if model.contextLimitProof { + VStack(spacing: 4) { + HStack { + Button("Reject context") { model.rejectContext() } + .accessibilityIdentifier("shipping-context.reject") + .disabled(!model.ready || model.runtime.hasUnresolvedRun) + Button(showingChat ? "Visit another screen" : "Return to chat") { showingChat.toggle() } + .accessibilityIdentifier("shipping-context.navigate") + .disabled(!model.ready) + }.font(.caption) + Text(model.summary).font(.caption2) + .accessibilityIdentifier("shipping-viewport.summary") + }.frame(maxWidth: .infinity).frame(height: 60) + } else if model.draftLoadProof { + VStack(spacing: 4) { + HStack { + Button("Release stored draft") { Task { await model.releaseDraftLoad() } } + .accessibilityIdentifier("shipping-draft.release") + .disabled(!model.draftLoadHeld) + Button(showingChat ? "Visit another screen" : "Return to chat") { showingChat.toggle() } + .accessibilityIdentifier("shipping-draft.navigate") + .disabled(!model.ready) + }.font(.caption) + Text(model.draftLoadHeld ? "Load held" : "Load resumed") + .accessibilityIdentifier("shipping-draft.state") + Text(model.summary).font(.caption2) + .accessibilityIdentifier("shipping-viewport.summary") + Text(model.runtime.draftText).font(.caption2).lineLimit(1) + .accessibilityIdentifier("shipping-draft.model") + } + .frame(maxWidth: .infinity).frame(height: 96) + } else { + regularControls(model) + } + } + + private func regularControls(_ model: ShippingViewportFixtureModel) -> some View { + VStack(spacing: 4) { + HStack { + Button("Release older") { Task { await model.releaseOlder() } } + .accessibilityIdentifier("shipping-viewport.release-older") + .disabled(!model.runtime.isLoadingOlder) + Button("Start run") { model.startRun() } + .accessibilityIdentifier("shipping-viewport.start") + .disabled(!model.ready || model.started) + Button("Emit chunks") { model.emitChunks() } + .accessibilityIdentifier("shipping-viewport.emit") + .disabled(model.runtime.phase != .streaming || model.emitting || model.chunks > 0) + Button("Finish") { Task { await model.finish() } } + .accessibilityIdentifier("shipping-viewport.finish") + .disabled(model.chunks != 40 || model.emitting || !model.runtime.hasUnresolvedRun) + if ProcessInfo.processInfo.arguments.contains("--maple-shipping-follow-scroll-proof") { + Button("Append item") { Task { await model.appendReplyItem() } } + .accessibilityIdentifier("shipping-viewport.append-item") + .disabled(model.chunks != 40 || model.emitting || !model.runtime.hasUnresolvedRun) + } + if ProcessInfo.processInfo.arguments.contains("--maple-shipping-item-poll-proof") { + Button("Poll new items") { Task { await model.pollItems() } } + .accessibilityIdentifier("shipping-viewport.poll") + .disabled(!model.ready || model.runtime.hasUnresolvedRun) + } + }.font(.caption) + Text(model.summary).font(.caption2).lineLimit(1).minimumScaleFactor(0.6) + .accessibilityIdentifier("shipping-viewport.summary") + .accessibilityValue(model.scrollState) + if ProcessInfo.processInfo.arguments.contains("--maple-shipping-follow-scroll-proof") + || ProcessInfo.processInfo.arguments.contains("--maple-shipping-item-poll-proof") + { + Text("Synthetic native viewport geometry").font(.caption2) + .accessibilityIdentifier("shipping-viewport.scroll-metrics") + .accessibilityValue(model.scrollMetricsJSON) + } + if resizeProof { + Text("Window ownership").font(.caption2) + .accessibilityIdentifier("shipping-window.ownership") + .accessibilityValue(model.windowOwnership) + } + } + .frame( + height: resizeProof + || ProcessInfo.processInfo.arguments.contains("--maple-shipping-follow-scroll-proof") ? 80 : 60) + } + } + + /// Exposes UIKit's actual laid-out bounds without assigning an accessibility frame. + private struct ShippingPaginationViewportProbe: UIViewRepresentable { + func makeUIView(context: Context) -> UIView { + let view = UIView() + view.backgroundColor = .clear + view.isUserInteractionEnabled = false + view.isAccessibilityElement = true + view.accessibilityLabel = "Synthetic retained Library viewport" + view.accessibilityIdentifier = "shipping-pagination.viewport" + return view + } + + func updateUIView(_ uiView: UIView, context: Context) {} + } + + /// The native title slot retains its content. Read observable inputs in the + /// hosted body rather than handing it a Text containing an earlier snapshot. + private struct ShippingPaginationNavigationDiagnostics: View { + @Bindable var model: ShippingViewportFixtureModel + @Bindable var lease: CompactMenuLease + + var body: some View { + VStack(spacing: 0) { + Text(model.summary).font(.caption2).lineLimit(1).minimumScaleFactor(0.6) + .accessibilityIdentifier("shipping-viewport.summary") + .accessibilityValue(model.paginationVisibilityJSON(lease: lease)) + Text("Synthetic retained Chat owners").font(.caption2) + .accessibilityIdentifier("shipping-window.ownership") + .accessibilityValue(model.windowOwnership) + } + .accessibilityElement(children: .contain) + } + } + + @MainActor @Observable private final class ShippingViewportFixtureModel { + let environment: AppEnvironment + let runtime: ChatRuntime + let account: AccountEntitlements + let preferences: PreferencesController + let optionsLibrary: LibraryModel + private let optionsService: LibraryControlsFixtureService + private var optionsEditors: [InstructionEditor] = [] + private var selectedOptionsProjects: [UUID: String] = [:] + private let service = ShippingViewportService( + complexMarkup: ProcessInfo.processInfo.arguments.contains("--maple-shipping-complex-markdown-proof"), + transcriptRows: ProcessInfo.processInfo.arguments.contains("--maple-shipping-transcript-rows-proof"), + historyRendering: ProcessInfo.processInfo.arguments.contains("--maple-shipping-history-rendering-proof"), + streamWidth: ProcessInfo.processInfo.arguments.contains("--maple-shipping-stream-width-proof"), + nativeNestedScroll: ProcessInfo.processInfo.arguments.contains( + "--maple-shipping-native-nested-scroll-proof"), + paginationBoundary: + ProcessInfo.processInfo.arguments.contains("--maple-shipping-mid-turn-pagination-proof") + ? .reasoning + : ProcessInfo.processInfo.arguments.contains("--maple-shipping-tool-boundary-pagination-proof") + ? .toolResult : nil) + private let store: ShippingViewportStore + let contextLimitProof = ProcessInfo.processInfo.arguments.contains("--maple-shipping-context-limit-proof") + let draftLoadProof: Bool + let compactProjectionProof: Bool + let coldHistoryProof: Bool + let historyRenderingProof = ProcessInfo.processInfo.arguments.contains( + "--maple-shipping-history-rendering-proof") + let streamWidthProof = ProcessInfo.processInfo.arguments.contains("--maple-shipping-stream-width-proof") + private let paginationBoundaryProof = + ProcessInfo.processInfo.arguments.contains("--maple-shipping-mid-turn-pagination-proof") + || ProcessInfo.processInfo.arguments.contains("--maple-shipping-tool-boundary-pagination-proof") + private let cachedProjection: ChatRuntime? + private let cachedProjectionInstance = UUID() + private(set) var projectionIsNewChat = true + var projectionMenuPresented = false + private let instanceID = UUID() + private(set) var draftLoadHeld = false + private(set) var coldHistoryNavigationSettled = false + @ObservationIgnored private var draftLoadTask: Task? + @ObservationIgnored private var retired = false + var ready = false + var started = false + var emitting = false + var chunks = 0 + var appendedItems = 0 + var widthReplyStage = 0 + var advancingWidthReply = false + private var widthToolGeometry: [String: Double] = [:] + private var horizontalScrollMetrics: [String: Double] = [:] + var scrollMetrics: TranscriptViewportMetrics? + var scrollState = "" + var paginationChatShownCalls = 0 + var paginationChatShownAdmissions = 0 + var paginationLeasePublications = 0 + var paginationVisibilityChanges = 0 + private var preparedDisappeared = 0 + private var preparedRetained = 0 + private var preparedCleared = 0 + var failed = false + @ObservationIgnored private var streamTask: Task? + + init() throws { + let draftLoadProof = + ProcessInfo.processInfo.arguments.contains("--maple-shipping-viewport-fixture") + && ProcessInfo.processInfo.arguments.contains("--maple-shipping-draft-load-proof") + self.draftLoadProof = draftLoadProof + let coldHistoryProof = + ProcessInfo.processInfo.arguments.contains("--maple-shipping-viewport-fixture") + && ProcessInfo.processInfo.arguments.contains("--maple-shipping-cold-history-proof") + self.coldHistoryProof = coldHistoryProof + let compactProjectionProof = + coldHistoryProof + || (ProcessInfo.processInfo.arguments.contains("--maple-shipping-viewport-fixture") + && ProcessInfo.processInfo.arguments.contains("--maple-shipping-compact-projection-proof")) + self.compactProjectionProof = compactProjectionProof + let store = ShippingViewportStore( + holdInitialLoad: draftLoadProof || coldHistoryProof, + heldChatID: coldHistoryProof ? ShippingViewportService.chatID : nil) + self.store = store + // This environment is never restored or activated. Inert loopback + // origins isolate its SDK namespace from the local signed-in app. + let configuration = try AppConfiguration( + profile: .local, apiURL: "http://127.0.0.1:9", billingURL: "http://127.0.0.1:9", + flagsURL: "http://127.0.0.1:9", authURL: "http://127.0.0.1:9", + clientID: "00000000-0000-4000-8000-000000000009", callbackScheme: "cloud.opensecret.maple.native.dev") + environment = try AppEnvironment(configuration: configuration) + let scope = AccountScope(apiOrigin: configuration.apiURL.absoluteString, principalID: "synthetic-viewport") + runtime = ChatRuntime( + scope: scope, service: service, persistence: store, + conversationID: ShippingViewportService.chatID) + cachedProjection = + compactProjectionProof + ? ChatRuntime(scope: scope, service: service, persistence: store) : nil + let entitlements = ShippingViewportEntitlements() + account = AccountEntitlements(scope: scope, isGuest: false, billing: entitlements, catalog: entitlements) + preferences = PreferencesController(scope: scope, store: ShippingViewportPreferences()) + let optionsService = LibraryControlsFixtureService( + failPage: false, failDelete: false, dragProof: false, optionsProof: true) + self.optionsService = optionsService + optionsLibrary = LibraryModel( + scope: scope, service: optionsService, + deletions: ChatDeletionCoordinator( + scope: scope, retained: [], service: optionsService, persistence: store)) + runtime.isVisible = true + } + + var optionsContext: ChatOptionsFixtureContext { + let scope = account.scope, service = optionsService, owner = selectedProjection.snapshot.storageOwner + return ChatOptionsFixtureContext( + library: optionsLibrary, + projectID: { [weak self] in self?.selectedOptionsProjects[owner] }, + makeInstructionEditor: { [weak self] destination in + let editor = InstructionEditor(scope: scope, destination: destination, service: service) + guard let self, projectionIsCurrent(scope: scope, storageOwner: owner) else { + editor.invalidate(); return editor + } + optionsEditors.append(editor); return editor + }, + selectProject: { [weak self] id in + guard let self, projectionIsCurrent(scope: scope, storageOwner: owner) else { return false } + selectedOptionsProjects[owner] = id; return true + }) + } + + var summary: String { + "Ready \(ready ? 1 : 0); older \(runtime.isLoadingOlder ? 1 : 0); messages \(runtime.messages.count); chunks \(chunks); running \(runtime.hasUnresolvedRun ? 1 : 0); failed \(failed ? 1 : 0)" + } + var scrollMetricsJSON: String { + guard let scrollMetrics, let data = try? JSONEncoder().encode(scrollMetrics) else { return "{}" } + return String(data: data, encoding: .utf8) ?? "{}" + } + func paginationVisibilityJSON(lease: CompactMenuLease) -> String { + let owner = runtime.snapshot.storageOwner + let state = lease.state(scope: runtime.scope, storageOwner: owner) + let value: [String: String] = [ + "visible": String(runtime.isVisible), "foreground": String(runtime.isForeground), + "scopeCurrent": String(lease.scope == runtime.scope && account.scope == runtime.scope), + "ownerCurrent": String(lease.storageOwner == owner), + "runtimeOwner": owner.uuidString, "leaseOwner": lease.storageOwner?.uuidString ?? "nil", + "returnRevision": String(state.returnRevision), "occluded": String(state.occluded), + "menuVisible": String(lease.menuVisible), "menuRequested": String(projectionMenuPresented), + "projectionCurrent": String(projectionIsCurrent(scope: runtime.scope, storageOwner: owner)), + "chatShownCalls": String(paginationChatShownCalls), + "chatShownAdmissions": String(paginationChatShownAdmissions), + "leasePublications": String(paginationLeasePublications), + "visibilityChanges": String(paginationVisibilityChanges), "scrollCallback": scrollState, + ] + guard let data = try? JSONEncoder().encode(value) else { return "{}" } + return String(data: data, encoding: .utf8) ?? "{}" + } + var widthToolGeometryJSON: String { + guard let data = try? JSONEncoder().encode(widthToolGeometry) else { return "{}" } + return String(data: data, encoding: .utf8) ?? "{}" + } + var horizontalScrollMetricsJSON: String { + guard let data = try? JSONEncoder().encode(horizontalScrollMetrics) else { return "{}" } + return String(data: data, encoding: .utf8) ?? "{}" + } + func recordHorizontalScrollMetrics(_ metrics: [String: CGFloat]) { + guard streamWidthProof, !retired, !metrics.isEmpty, metrics.values.allSatisfy(\.isFinite) else { return } + var values = metrics.mapValues(Double.init) + values["samples"] = (horizontalScrollMetrics["samples"] ?? 0) + 1 + for key in ["offsetX", "contentWidth", "boundsMinX"] { + guard let value = values[key] else { continue } + let suffix = key.prefix(1).uppercased() + String(key.dropFirst()) + let minimum = "minimum" + suffix, maximum = "maximum" + suffix + values[minimum] = min(horizontalScrollMetrics[minimum] ?? value, value) + values[maximum] = max(horizontalScrollMetrics[maximum] ?? value, value) + } + horizontalScrollMetrics = values + } + func recordWidthToolHorizontalGeometry(itemID: String, geometry: TranscriptItemHorizontalGeometry) { + guard streamWidthProof, !retired, itemID == "viewport-width-search" else { return } + let x = Double(geometry.minX), width = Double(geometry.width) + var values = widthToolGeometry + values["samples"] = (values["samples"] ?? 0) + 1 + values["minimumX"] = min(values["minimumX"] ?? x, x) + values["maximumX"] = max(values["maximumX"] ?? x, x) + values["minimumWidth"] = min(values["minimumWidth"] ?? width, width) + values["maximumWidth"] = max(values["maximumWidth"] ?? width, width) + widthToolGeometry = values + } + var renderLifecyclesJSON: String { + let values = [ + "preparedDisappeared": preparedDisappeared, + "preparedRetained": preparedRetained, + "preparedCleared": preparedCleared, + ] + guard let data = try? JSONEncoder().encode(values) else { return "{}" } + return String(data: data, encoding: .utf8) ?? "{}" + } + func recordRenderingDisappearance(prepared: Bool, retained: Bool) { + guard historyRenderingProof, !retired, prepared else { return } + preparedDisappeared += 1 + if retained { preparedRetained += 1 } else { preparedCleared += 1 } + } + func prepare() async { + guard !retired else { return } + await preferences.load() + guard !retired, !Task.isCancelled else { return } + await account.refresh() + await optionsLibrary.loadProjects() + guard !retired, !Task.isCancelled else { return } + if coldHistoryProof, let cachedProjection { + await cachedProjection.load(chatID: nil) + guard !retired, !Task.isCancelled else { return } + cachedProjection.isVisible = true; runtime.isVisible = false + ready = preferences.state == .ready && account.chatAccess == .allowed + failed = !ready + return + } + if draftLoadProof { + await store.seed( + ChatSnapshot( + storageKey: ShippingViewportService.chatID, conversationID: ShippingViewportService.chatID, + draft: ChatDraft(text: "Stored synthetic draft arrives before typing"))) + guard !retired, !Task.isCancelled else { return } + let runtime = runtime + let loading = Task { await runtime.load(chatID: ShippingViewportService.chatID) } + draftLoadTask = loading + await store.waitForInitialLoad() + guard !retired, !Task.isCancelled else { + loading.cancel(); await store.cancelInitialLoad(); return + } + draftLoadHeld = true + await loading.value + draftLoadTask = nil; draftLoadHeld = false + } else { + await runtime.load(chatID: ShippingViewportService.chatID) + } + guard !retired, !Task.isCancelled else { return } + if compactProjectionProof, let cachedProjection { + await cachedProjection.load(chatID: nil) + guard !retired, !Task.isCancelled else { return } + runtime.draftText = "Cached B draft stays separate" + } + ready = + preferences.state == .ready && account.chatAccess == .allowed + && runtime.messages.count == (historyRenderingProof ? 12 : paginationBoundaryProof ? 20 : 8) + failed = !ready + } + var selectedProjection: ChatRuntime { + projectionIsNewChat ? cachedProjection ?? runtime : runtime + } + func projectionIsCurrent(scope: AccountScope) -> Bool { + !retired && account.scope == scope && selectedProjection.scope == scope + } + func projectionIsCurrent(scope: AccountScope, storageOwner: UUID) -> Bool { + projectionIsCurrent(scope: scope) && selectedProjection.snapshot.storageOwner == storageOwner + } + func selectProjection(newChat: Bool) { + guard compactProjectionProof, ready, !retired, cachedProjection != nil else { return } + // Select an already-loaded runtime only. No load, draft mutation or + // deferred model publication may repair the returned projection. + projectionIsNewChat = newChat + } + var coldHistoryState: String { + "Ready \(ready ? 1 : 0); selected \(projectionIsNewChat ? "new" : "existing"); held \(draftLoadHeld ? 1 : 0); settled \(coldHistoryNavigationSettled ? 1 : 0); messages \(runtime.messages.count); loading \(runtime.isLoading ? 1 : 0)" + } + /// Synthetic selection models publication before presentation. It does + /// not replace or exercise AppEnvironment's real selection callback. + func selectColdHistory() async { + guard coldHistoryProof, ready, !retired, projectionIsNewChat, + projectionMenuPresented, let cachedProjection, + cachedProjection.scope == account.scope, runtime.scope == account.scope + else { return } + cachedProjection.isVisible = false; runtime.isVisible = true + projectionIsNewChat = false; coldHistoryNavigationSettled = false + projectionMenuPresented = false + let loading = Task { await runtime.load(chatID: ShippingViewportService.chatID) } + draftLoadTask = loading + await store.waitForInitialLoad() + guard projectionIsCurrent(scope: account.scope), selectedProjection === runtime, + !Task.isCancelled + else { loading.cancel(); await store.cancelInitialLoad(); return } + draftLoadHeld = true + await loading.value + draftLoadTask = nil; draftLoadHeld = false + } + func completeColdHistoryNavigation(scope: AccountScope) { + guard coldHistoryProof, projectionIsCurrent(scope: scope), selectedProjection === runtime, + !projectionIsNewChat, !projectionMenuPresented, + runtime.conversationID == ShippingViewportService.chatID + else { return } + coldHistoryNavigationSettled = true + } + func releaseColdHistory() async { + guard coldHistoryProof, coldHistoryNavigationSettled, draftLoadHeld, + projectionIsCurrent(scope: account.scope), selectedProjection === runtime, + !projectionMenuPresented, runtime.conversationID == ShippingViewportService.chatID + else { return } + await store.releaseInitialLoad() + } + var projectionOwnership: String { + guard let cachedProjection else { return "{}" } + let value: [String: String] = [ + "selected": projectionIsNewChat ? "A" : "B", + "aInstance": cachedProjectionInstance.uuidString, "bInstance": instanceID.uuidString, + "aStorageOwner": cachedProjection.snapshot.storageOwner.uuidString, + "aDraftOwner": cachedProjection.snapshot.draft.id.uuidString, + "bStorageOwner": runtime.snapshot.storageOwner.uuidString, + "bDraftOwner": runtime.snapshot.draft.id.uuidString, + "aText": cachedProjection.draftText, "bText": runtime.draftText, + "aLoading": cachedProjection.isLoading ? "true" : "false", + "bLoading": runtime.isLoading ? "true" : "false", + "running": cachedProjection.hasUnresolvedRun || runtime.hasUnresolvedRun ? "true" : "false", + ] + let encoder = JSONEncoder(); encoder.outputFormatting = [.sortedKeys] + return (try? encoder.encode(value)).flatMap { String(data: $0, encoding: .utf8) } ?? "{}" + } + func releaseDraftLoad() async { + guard draftLoadProof, draftLoadHeld, !retired else { return } + await store.releaseInitialLoad() + } + /// Resize tests retain actual runtime draft/queue owners. The initial + /// synthetic data is admitted by the runtime; no Responses call is made. + func prepareWindowOwnership() { + guard ready, !runtime.hasUnresolvedRun, runtime.queuedTurns.isEmpty else { failed = true; return } + runtime.draftText = "Window queued A remains paused" + guard runtime.enqueueDraft() else { failed = true; return } + runtime.draftText = "Window held draft B" + } + var windowOwnership: String { + let value: [String: String] = [ + "instance": instanceID.uuidString, + "storageOwner": runtime.snapshot.storageOwner.uuidString, + "draftOwner": runtime.snapshot.draft.id.uuidString, + "queueOwner": runtime.queuedTurns.first?.id.uuidString ?? "none", + "queueDraftOwner": runtime.queuedTurns.first?.draft.id.uuidString ?? "none", + "queueMessageID": runtime.queuedTurns.first?.messageID ?? "none", + "queueCount": String(runtime.queuedTurns.count), + "queuePaused": runtime.isQueuePaused ? "true" : "false", + "queuedText": runtime.queuedTurns.first?.draft.text ?? "", + "running": runtime.hasUnresolvedRun ? "true" : "false", + ] + let encoder = JSONEncoder(); encoder.outputFormatting = [.sortedKeys] + return (try? encoder.encode(value)).flatMap { String(data: $0, encoding: .utf8) } ?? "{}" + } + func releaseOlder() async { await service.releaseOlder() } + func pollItems() async { + guard ready, !retired, + projectionIsCurrent(scope: runtime.scope, storageOwner: runtime.snapshot.storageOwner) + else { return } + await runtime.pollNewItems() + } + func rejectContext() { + guard contextLimitProof, ready, !retired, !runtime.hasUnresolvedRun else { return } + runtime.draftText = "Synthetic rejected context message" + failed = !runtime.send() + } + func startRun() { + guard ready, !started else { return } + runtime.draftText = "Synthetic live viewport turn" + started = runtime.send() + failed = !started + } + func emitChunks() { + guard runtime.phase == .streaming, !emitting, chunks == 0 else { return } + emitting = true + streamTask = Task { [weak self] in + guard let self else { return } + defer { emitting = false } + do { + for index in 0..<40 { + try Task.checkCancellation() + try await service.append(index: index) + chunks = index + 1 + try await Task.sleep(for: .milliseconds(40)) + } + } catch { if !Task.isCancelled { failed = true } } + } + } + func finish() async { + do { try await service.finish() } catch { failed = true } + } + func advanceWidthReply() async { + guard streamWidthProof, !retired, !advancingWidthReply, widthReplyStage < 3, + runtime.phase == .streaming + else { return } + advancingWidthReply = true + defer { advancingWidthReply = false } + do { + try await service.advanceWidthReply(stage: widthReplyStage + 1) + widthReplyStage += 1 + } catch { failed = true } + } + func appendReplyItem() async { + guard ProcessInfo.processInfo.arguments.contains("--maple-shipping-follow-scroll-proof"), + !retired, !emitting, chunks == 40, runtime.phase == .streaming + else { return } + do { + try await service.appendReplyItem(index: appendedItems) + appendedItems += 1 + } catch { failed = true } + } + func retire() { + guard !retired else { return } + retired = true; draftLoadTask?.cancel(); streamTask?.cancel(); runtime.invalidate() + cachedProjection?.invalidate() + optionsLibrary.invalidate() + for editor in optionsEditors { editor.invalidate() } + optionsEditors.removeAll() + Task { + await store.cancelInitialLoad(); await service.abort() + } + } + } + + private actor ShippingViewportService: ChatServing { + enum PaginationBoundary: Sendable { case reasoning, toolResult } + static let chatID = "synthetic-viewport-chat" + private let complexMarkup: Bool + private let transcriptRows: Bool + private let historyRendering: Bool + private let streamWidth: Bool + private let nativeNestedScroll: Bool + private let paginationBoundary: PaginationBoundary? + private var older: CheckedContinuation, Error>? + private var stream: AsyncThrowingStream.Continuation? + private var text = "Synthetic live reply." + private var retired = false + private let pollingProof = ProcessInfo.processInfo.arguments.contains("--maple-shipping-item-poll-proof") + + init( + complexMarkup: Bool = false, transcriptRows: Bool = false, historyRendering: Bool = false, + streamWidth: Bool = false, nativeNestedScroll: Bool = false, paginationBoundary: PaginationBoundary? = nil + ) { + self.complexMarkup = complexMarkup; self.transcriptRows = transcriptRows + self.historyRendering = historyRendering + self.streamWidth = streamWidth + self.nativeNestedScroll = nativeNestedScroll + self.paginationBoundary = paginationBoundary + } + + func createConversation(metadata: [String: JSONValue]?, projectId: String?, pinned: Bool?) throws + -> Conversation + { + throw ChatIssue.uncertainResponse + } + func listConversations(_ parameters: ConversationsListParameters) -> APIPage { APIPage(data: []) } + func listConversationItems(conversationId: String, parameters: ListParameters) async throws -> APIPage< + ConversationItem + > { + guard !retired, conversationId == Self.chatID else { + throw ChatIssue.historyUnavailable + } + if pollingProof, parameters.order == .asc, parameters.limit == 20, + parameters.after == "viewport-assistant-23" + { + return APIPage( + data: [ + ConversationItem( + id: "viewport-polled-user", type: "message", status: "completed", role: "user", + content: [MessageContent(type: "input_text", text: "Synthetic poll from another device")]), + ConversationItem( + id: "viewport-polled-answer", type: "message", status: "completed", role: "assistant", + content: [ + MessageContent(type: "output_text", text: "A newly polled answer reaches the bottom.") + ]), + ], hasMore: false) + } + if pollingProof, parameters.order == .asc, parameters.after == "viewport-polled-answer" { + return APIPage(data: [], hasMore: false) + } + guard parameters.order == .desc else { throw ChatIssue.historyUnavailable } + if parameters.after == nil { + if historyRendering { return Self.historyPage() } + if let paginationBoundary { return Self.boundaryPage(paginationBoundary, older: false) } + return transcriptRows + ? try Self.transcriptPage() + : Self.page( + 20..<24, hasMore: true, complexMarkup: complexMarkup, nativeNestedScroll: nativeNestedScroll) + } + let boundaryID: String + switch paginationBoundary { + case .some(.reasoning): boundaryID = "viewport-reasoning-20" + case .some(.toolResult): boundaryID = "viewport-search-result-20" + case nil: boundaryID = "viewport-user-20" + } + guard parameters.after == boundaryID, older == nil else { throw ChatIssue.historyUnavailable } + return try await withCheckedThrowingContinuation { older = $0 } + } + func releaseOlder() { + let pending = older; older = nil + let page = + if let paginationBoundary { Self.boundaryPage(paginationBoundary, older: true) } else { + Self.page(10..<20, hasMore: false, complexMarkup: complexMarkup) + } + pending?.resume(returning: page) + } + /// Twenty-item pages deliberately split a turn rather than aligning on + /// its user message. The tool variant also replaces a standalone result + /// row when its matching call becomes available in the older page. + private static func boundaryPage(_ boundary: PaginationBoundary, older: Bool) -> APIPage { + let user = ConversationItem( + id: "viewport-user-20", type: "message", status: "completed", role: "user", + content: [MessageContent(type: "input_text", text: "Viewport reading anchor 20")]) + let assistant = ConversationItem( + id: "viewport-assistant-20", type: "message", status: "completed", role: "assistant", + content: [MessageContent(type: "output_text", text: "The same completed reply remains in this turn.")]) + let ascending: [ConversationItem] + switch (boundary, older) { + case (.reasoning, false): + let reasoning = ConversationItem( + id: "viewport-reasoning-20", type: "reasoning", status: "completed", + content: [MessageContent(type: "reasoning_text", text: "Completed synthetic boundary reasoning.")]) + ascending = + [reasoning, assistant] + page(21..<30, hasMore: false, complexMarkup: false).data.reversed() + case (.reasoning, true): + ascending = + Array(page(10..<20, hasMore: false, complexMarkup: false).data.reversed().dropFirst()) + [user] + case (.toolResult, false): + let output = ConversationItem( + id: "viewport-search-result-20", type: "function_call_output", status: "completed", + callId: "viewport-boundary-search", + output: .string("The same completed search result survives pairing.")) + ascending = [output, assistant] + page(21..<30, hasMore: false, complexMarkup: false).data.reversed() + case (.toolResult, true): + let call = ConversationItem( + id: "viewport-search-20", type: "function_call", status: "completed", role: "assistant", + callId: "viewport-boundary-search", name: "web_search", + arguments: .string("{\"query\":\"synthetic boundary\"}")) + ascending = Array(page(10..<19, hasMore: false, complexMarkup: false).data.reversed()) + [user, call] + } + return APIPage( + data: Array(ascending.reversed()), firstId: ascending.last?.id, + lastId: ascending.first?.id, hasMore: !older) + } + /// Decode both API representations through the SDK item model. A + /// conversation page uses function_call; retrieved Responses output + /// uses tool_call. Both preserve string arguments and call_id pairing. + private static func transcriptPage() throws -> APIPage { + let json = #""" + [ + {"id":"viewport-user-20","type":"message","status":"completed","role":"user", + "content":[{"type":"input_text","text":"A synthetic message on the right"}]}, + {"id":"viewport-reasoning-20","type":"reasoning","status":"completed", + "content":[{"type":"reasoning_text","text":"Synthetic tool research reasoning"}]}, + {"id":"viewport-search-20","response_id":"synthetic-response","type":"function_call", + "status":"completed","role":"assistant","call_id":"synthetic-search","name":"web_search", + "arguments":"{\"query\":\"synthetic garden\"}"}, + {"id":"viewport-search-result-20","response_id":"synthetic-response","type":"function_call_output", + "status":"completed","call_id":"synthetic-search","output":"Synthetic searchable garden result"}, + {"id":"viewport-native-search-20","type":"web_search_call","status":"completed","role":"assistant"}, + {"id":"viewport-open-20","response_id":"synthetic-response","type":"tool_call", + "status":"completed","role":"assistant","call_id":"synthetic-open","name":"open_urls", + "arguments":"{\"urls\":[\"https://example.com/garden\"]}"}, + {"id":"viewport-open-result-20","response_id":"synthetic-response","type":"tool_output", + "status":"completed","call_id":"synthetic-open","output":"Synthetic opened page contents"}, + {"id":"viewport-assistant-20","type":"message","status":"completed","role":"assistant", + "content":[{"type":"output_text","text":"The synthetic research reply stays on the left."}]} + ] + """# + let decoder = JSONDecoder(); decoder.keyDecodingStrategy = .convertFromSnakeCase + let ascending = try decoder.decode([ConversationItem].self, from: Data(json.utf8)) + return APIPage( + data: Array(ascending.reversed()), firstId: ascending.last?.id, + lastId: ascending.first?.id, hasMore: false) + } + private static func page( + _ indices: Range, hasMore: Bool, complexMarkup: Bool, nativeNestedScroll: Bool = false + ) -> APIPage { + let ascending = indices.flatMap { index in + [ + ConversationItem( + id: "viewport-user-\(index)", type: "message", status: "completed", role: "user", + content: [MessageContent(type: "input_text", text: "Viewport reading anchor \(index)")]), + ConversationItem( + id: "viewport-assistant-\(index)", type: "message", status: "completed", role: "assistant", + content: [ + MessageContent( + type: "output_text", + text: + nativeNestedScroll && index == 20 + ? nativeNestedReply() + : complexMarkup + ? complexReply(index) + : "Synthetic reply \(index). **Bold**, *emphasis*, and `inline code` use the shipping renderer.\n\nA second paragraph makes this turn taller than a plain list row." + ) + ]), + ] + } + return APIPage( + data: Array(ascending.reversed()), firstId: ascending.last?.id, + lastId: ascending.first?.id, hasMore: hasMore) + } + private static func complexReply(_ index: Int) -> String { + """ + # Synthetic Garden Notes \(index) + + This **synthetic** response exercises *retained* Markdown, tables, and selectable code. + + > A retained chat keeps its reading position while a native menu moves across the screen. + + - [ ] Record the assistant's position + - [x] Keep the same draft and runtime owners + + | Language | Description | Example | + |:---|:---:|---:| + | Swift | A deliberately wide synthetic column exercises horizontal overflow | 26 | + | Arabic | مرحبا | 42 | + + ```swift + let retainedDraft = "Synthetic native navigation" + let position = CGPoint(x: 20, y: 100) + ``` + + ## A second section + + The menu previews underneath this screen. Releasing a short gesture returns the same content. + + This paragraph deliberately gives the accessibility container content beyond the visible viewport. + + Multilingual text remains selectable: مرحبا, こんにちは, שלום, 🌿. + """ + } + /// Overflow exceeds the transcript's 850-point cap on both device families. + /// The native menu fixture exercises the real nested scrollers and pop admission. + private static func nativeNestedReply() -> String { + """ + Native nested-scroll reading anchor. + + | First synthetic nested column | Second synthetic nested column | Third synthetic nested column | Fourth synthetic nested column | Fifth synthetic nested column | Sixth synthetic nested column | Seventh synthetic nested column | + |:---|:---|:---|:---|:---|:---|:---| + | Native nested table first | Another deliberately broad observation | A third deliberately broad observation | A fourth deliberately broad observation | A fifth deliberately broad observation | A sixth deliberately broad observation | Native nested table last | + + Native nested code reading anchor. + + ```swift + let native_nested_scroll_identifier = "ThisDeliberatelyUnbrokenSyntheticCodeLineIsLongEnoughToOverflowTheFullEightHundredAndFiftyPointTranscriptColumnWhileOnlyItsOwnCodeViewportScrollsHorizontally" + ``` + + Native nested-scroll final reading anchor. + """ + } + /// A completed multi-turn topic keeps rich replies taller than the + /// viewport so ordinary history scrolling crosses lazy-row lifetimes. + /// No account, parser, publication deadline or renderer is substituted. + private static func historyPage() -> APIPage { + let ascending = (20..<26).flatMap { index in + [ + ConversationItem( + id: "viewport-user-\(index)", type: "message", status: "completed", role: "user", + content: [ + MessageContent( + type: "input_text", text: "Continue the synthetic garden discussion, turn \(index).") + ]), + ConversationItem( + id: "viewport-assistant-\(index)", type: "message", status: "completed", role: "assistant", + content: [MessageContent(type: "output_text", text: historyReply(index))]), + ] + } + return APIPage( + data: Array(ascending.reversed()), firstId: ascending.last?.id, + lastId: ascending.first?.id, hasMore: false) + } + private static func historyReply(_ index: Int) -> String { + """ + # Synthetic Garden Notes \(index) + + This **synthetic garden** reply continues the same topic with *formatted* notes and `native code`. + + [Garden reference \(index)](https://example.com/garden/\(index)) + + ```swift + let gardenTurn = \(index) + let readingEarlierNotes = true + ``` + + ## Soil and water + + The earlier notes describe a small synthetic garden with several planting beds. Reading this discussion means moving between completed replies, comparing the soil notes, and returning to later details without losing the structure of either message. + + - Compare the **earlier plan** with this turn's watering notes. + - Keep the *same topic* in view while checking another completed reply. + - Read `gardenTurn` as selectable code rather than its Markdown fence. + + > Each follow-up adds more detail to the same synthetic discussion. A reader can pause anywhere in the transcript before continuing to an earlier or later turn. + + ## Seasonal observations + + A second long paragraph makes the reading target extend beyond the first screen on a phone. The synthetic beds have different shade and drainage, so the discussion records the same observations in several turns. These paragraphs exercise native text selection, wrapping, and ordinary vertical history scrolling. + + | Bed | Observation | + |:---|:---| + | North | Morning shade and steady moisture | + | South | Afternoon sun and slower watering | + + The final note for turn \(index) remains part of this completed reply when the reader revisits the conversation. + """ + } + func createResponse(_ request: ResponsesCreateRequest, maximumInferenceSends: Int) throws + -> AsyncThrowingStream + { + guard !retired, stream == nil, request.conversation == Self.chatID else { + throw ChatIssue.uncertainResponse + } + if ProcessInfo.processInfo.arguments.contains("--maple-shipping-context-limit-proof") { + throw ResearchAPIError.http(status: 413, reason: .contextLimit) + } + let pair = AsyncThrowingStream.makeStream() + stream = pair.continuation + stream?.yield(.created(try lifecycle(status: .inProgress, output: []))) + struct Item: Encodable { let outputIndex: Int; let item: ConversationItem } + if streamWidth { stream?.yield(.outputItemAdded(try event(Item(outputIndex: 0, item: widthReplyTool)))) } + stream?.yield( + .outputItemAdded( + try event(Item(outputIndex: streamWidth ? 1 : 0, item: assistant(status: "in_progress"))))) + return pair.stream + } + func append(index: Int) throws { + guard !retired, let stream else { throw CancellationError() } + let delta = " Synthetic chunk \(index) grows the last reply while earlier text remains the reading target." + text += delta + struct Delta: Encodable { + let itemId = "viewport-live-assistant" + let outputIndex = 0 + let contentIndex = 0 + let delta: String + } + stream.yield(.outputTextDelta(try event(Delta(delta: delta)))) + } + func finish() throws { + guard !retired, let current = stream else { throw CancellationError() } + let output = (streamWidth ? [widthReplyTool] : []) + [assistant(status: "completed")] + current.yield(.completed(try lifecycle(status: .completed, output: output))) + current.finish(); stream = nil + } + /// Held revisions use ordinary Responses events. The production parser, + /// publication throttle and transcript layout stay unchanged. + func advanceWidthReply(stage: Int) throws { + guard streamWidth, !retired, let stream, (1...3).contains(stage) else { throw CancellationError() } + let delta: String + switch stage { + case 1: + delta = """ + + + This **formatted follow-up** grows across several lines while the preceding tool and reply actions stay in the same transcript column. + + [Synthetic width reference](https://example.com/width) + """ + case 2: + delta = """ + + + | First wide column | Second wide column | Third wide column | Fourth wide column | Fifth wide column | Sixth wide column | Seventh wide column | + |:---|:---|:---|:---|:---|:---|:---| + | Synthetic table begins | Another synthetic observation | A third broad observation | A fourth broad observation | A fifth broad observation | A sixth broad observation | A seventh broad observation | + """ + default: + delta = """ + + | Synthetic table grows | DeliberatelyUnbrokenSyntheticTableObservationForHorizontalOverflow | Earlier streamed prose keeps its left edge | Tool rows retain their column | Copy remains in the same place | Speech remains in the same place | The table scrolls within its reply | + + ```swift + let syntheticWidth = "DeliberatelyUnbrokenSyntheticCodeLineThatMustScrollInsideTheReplyWithoutMovingTheTranscriptColumn" + ``` + """ + } + text += delta + struct Delta: Encodable { + let itemId = "viewport-live-assistant" + let outputIndex = 1 + let contentIndex = 0 + let delta: String + } + stream.yield(.outputTextDelta(try event(Delta(delta: delta)))) + } + private var widthReplyTool: ConversationItem { + ConversationItem( + id: "viewport-width-search", responseId: "viewport-response", type: "web_search_call", + status: "completed", role: "assistant") + } + func appendReplyItem(index: Int) throws { + guard !retired, let stream else { throw CancellationError() } + let item = ConversationItem( + id: "viewport-follow-item-\(index)", responseId: "viewport-response", type: "message", + status: "in_progress", role: "assistant", + content: [ + MessageContent( + type: "output_text", + text: String( + repeating: "A synthetic appended reply block verifies following at the bottom. ", count: 12) + ) + ]) + struct Item: Encodable { let outputIndex: Int; let item: ConversationItem } + stream.yield(.outputItemAdded(try event(Item(outputIndex: index + 1, item: item)))) + } + private func assistant(status: String) -> ConversationItem { + ConversationItem( + id: "viewport-live-assistant", responseId: "viewport-response", type: "message", status: status, + role: "assistant", content: [MessageContent(type: "output_text", text: text)]) + } + private func lifecycle(status: ResponseStatus, output: [ConversationItem]) throws -> ResponseLifecycleEvent { + struct Wrapper: Encodable { let response: ResearchResponse } + return try event( + Wrapper( + response: ResearchResponse( + id: "viewport-response", object: "response", createdAt: 0, + status: status, model: "auto:quick", output: output))) + } + private func event(_ value: Value) throws -> T { + let encoder = JSONEncoder(); encoder.keyEncodingStrategy = .convertToSnakeCase + let decoder = JSONDecoder(); decoder.keyDecodingStrategy = .convertFromSnakeCase + return try decoder.decode(T.self, from: encoder.encode(value)) + } + func abort() { + retired = true + older?.resume(throwing: CancellationError()); older = nil + stream?.finish(throwing: CancellationError()); stream = nil + } + func retrieveResponse(id: String) throws -> ResearchResponse { throw ChatIssue.uncertainResponse } + func cancelResponse(id: String) throws -> ResearchResponse { throw ChatIssue.cancellationUnavailable } + func getConversationItem(conversationId: String, itemId: String) throws -> ConversationItem { + throw ResearchAPIError.http(status: 404, reason: .notFound) + } + } + + private actor ShippingViewportStore: ChatPersistence { + private var values: [String: ChatSnapshot] = [:] + private var holdInitialLoad: Bool + private let heldChatID: String? + private var initialLoadEntered = false + private var retired = false + private var arrival: CheckedContinuation? + private var pendingLoad: (String, CheckedContinuation)? + + init(holdInitialLoad: Bool = false, heldChatID: String? = nil) { + self.holdInitialLoad = holdInitialLoad; self.heldChatID = heldChatID + } + func seed(_ snapshot: ChatSnapshot) { values[snapshot.storageKey] = snapshot } + func waitForInitialLoad() async { + guard !initialLoadEntered, !retired else { return } + await withCheckedContinuation { arrival = $0 } + } + func load(chatID: String, scope: AccountScope) async throws -> ChatSnapshot? { + guard !retired else { throw CancellationError() } + guard holdInitialLoad, heldChatID == nil || heldChatID == chatID else { return values[chatID] } + holdInitialLoad = false; initialLoadEntered = true + return try await withCheckedThrowingContinuation { continuation in + pendingLoad = (chatID, continuation) + let waiter = arrival; arrival = nil; waiter?.resume() + } + } + func releaseInitialLoad() { + guard !retired, let pending = pendingLoad else { return } + pendingLoad = nil + pending.1.resume(returning: values[pending.0]) + } + func cancelInitialLoad() { + retired = true; holdInitialLoad = false + let waiter = arrival; arrival = nil; waiter?.resume() + let pending = pendingLoad; pendingLoad = nil + pending?.1.resume(throwing: CancellationError()) + } + func loadAll(scope: AccountScope) -> [ChatSnapshot] { Array(values.values) } + func save(_ snapshot: ChatSnapshot, scope: AccountScope) { values[snapshot.storageKey] = snapshot } + func remove(chatID: String, scope: AccountScope) { values.removeValue(forKey: chatID) } + func rekey(from: String, to snapshot: ChatSnapshot, scope: AccountScope) { + values.removeValue(forKey: from); values[snapshot.storageKey] = snapshot + } + } + private actor ShippingViewportPreferences: PreferencesStoring { + private var value = LocalPreferences() + func activate(_ scope: AccountScope) -> LocalPreferences { value } + func save(_ value: LocalPreferences, scope: AccountScope, revision: UInt64) { self.value = value } + func retire(_ scope: AccountScope, removePersisted: Bool) {} + } + private actor ShippingViewportEntitlements: BillingServing, ModelCatalogServing { + func subscriptionStatus() throws -> BillingStatus { + try decode([ + "is_subscribed": true, "product_id": "synthetic", "product_name": "Pro", + "subscription_status": "active", "can_chat": true, "chats_remaining": 50, + ]) + } + func teamStatus() throws -> TeamStatus { + try decode(["has_team_subscription": false, "team_created": false]) + } + func fetchCatalog() -> ModelCatalog { + let pickerProof = + ProcessInfo.processInfo.arguments.contains("--maple-shipping-viewport-fixture") + && ProcessInfo.processInfo.arguments.contains("--maple-shipping-picker-proof") + let capabilities = ModelCapabilities(chat: true, vision: false, reasoning: true, toolUse: pickerProof) + let choices = [ + ("synthetic-quick", "Quick target", "Quick"), + ("synthetic-powerful", "Powerful target", "Powerful"), + ("synthetic-advanced-1", "Synthetic advanced 1", "Advanced 1"), + ("synthetic-advanced-2", "Synthetic advanced 2", "Advanced 2"), + ("synthetic-advanced-3", "Synthetic advanced 3", "Advanced 3"), + ("synthetic-advanced-4", "Synthetic advanced 4", "Advanced 4"), + ] + return ModelCatalog( + object: "list", + data: choices.enumerated().filter { pickerProof || $0.offset == 0 }.map { index, choice in + ModelCatalogItem( + id: choice.0, object: "model", created: 0, + ownedBy: "synthetic", displayName: choice.1, shortName: choice.2, + description: pickerProof ? "Synthetic model choice for picker interaction" : nil, + contextWindow: 32768, + maxContextTokens: 32768, + access: "free", capabilities: capabilities, enabled: true, deprecated: false, + sortOrder: pickerProof ? index : nil) + }, + aliases: [ + ModelAlias( + id: .quick, label: "Quick", shortName: "Quick", description: "Synthetic quick model", + targetModel: "synthetic-quick", access: "free", capabilities: capabilities) + ] + + (pickerProof + ? [ + ModelAlias( + id: .powerful, label: "Powerful", shortName: "Powerful", + description: "Synthetic powerful model", + targetModel: "synthetic-powerful", access: "free", capabilities: capabilities) + ] : []), + defaults: ModelDefaults(quick: .quick, powerful: .powerful), + audio: AudioModelCatalog( + transcription: AudioModelAvailability(available: true, model: "synthetic-stt"), + speech: AudioModelAvailability(available: true, model: "synthetic-tts"))) + } + func retire() {} + private func decode(_ value: [String: Any]) throws -> T { + let decoder = JSONDecoder(); decoder.keyDecodingStrategy = .convertFromSnakeCase + return try decoder.decode(T.self, from: JSONSerialization.data(withJSONObject: value)) + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/Auth.xcstrings b/apps/maple-ios/Sources/MapleAppUI/Resources/Auth.xcstrings new file mode 100644 index 000000000..df151a993 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Resources/Auth.xcstrings @@ -0,0 +1,618 @@ +{ + "sourceLanguage": "en", + "strings": { + "Account deletion code": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Account deletion code" + } + } + } + }, + "Account deletion was not confirmed. Check your account before trying again.": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Account deletion was not confirmed. Check your account before trying again." + } + } + } + }, + "Any required billing cleanup must finish before the account can be deleted.": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Any required billing cleanup must finish before the account can be deleted." + } + } + } + }, + "Browser sign-in isn’t available in this build. Sign in with email or an existing guest account.": { + "comment": "Browser provider sign-in is unavailable because this application target is not supported by the current callback broker. Do not imply a connectivity or credential failure.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser sign-in isn’t available in this build. Sign in with email or an existing guest account." + } + } + } + }, + "Cancel sign-in": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel sign-in" + } + } + } + }, + "Change password": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Change password" + } + } + } + }, + "Check the code and make sure your new passwords match. Use at least eight characters.": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check the code and make sure your new passwords match. Use at least eight characters." + } + } + } + }, + "Check your inbox for a verification email.": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check your inbox for a verification email." + } + } + } + }, + "Confirm account": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirm account" + } + } + } + }, + "Confirm account deletion": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirm account deletion" + } + } + } + }, + "Confirm new password": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirm new password" + } + } + } + }, + "Continue with GitHub": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Continue with GitHub" + } + } + } + }, + "Continue with Google": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Continue with Google" + } + } + } + }, + "Continue with reset": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Continue with reset" + } + } + } + }, + "Continue with this account": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Continue with this account" + } + } + } + }, + "Current password": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Current password" + } + } + } + }, + "Delete account": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete account" + } + } + } + }, + "Eight-character reset code": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Eight-character reset code" + } + } + } + }, + "Email": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Email" + } + } + } + }, + "If a code does not arrive, check spam or try the provider you used to create your account.": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "If a code does not arrive, check spam or try the provider you used to create your account." + } + } + } + }, + "Maple could not safely stop account activity. Your account was not deleted. Try again.": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Maple could not safely stop account activity. Your account was not deleted. Try again." + } + } + } + }, + "New password": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New password" + } + } + } + }, + "Only continue if this is the account you intended to use.": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Only continue if this is the account you intended to use." + } + } + } + }, + "Password reset creates a new private key and removes private encrypted content, including chats and saved data. If you can still sign in, change your password in settings instead.": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Password reset creates a new private key and removes private encrypted content, including chats and saved data. If you can still sign in, change your password in settings instead." + } + } + } + }, + "Privacy": { + "comment": "Auth footer link to Privacy policy.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Privacy" + } + } + } + }, + "Request deletion code": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Request deletion code" + } + } + } + }, + "Request password reset": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Request password reset" + } + } + } + }, + "Reset password": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset password" + } + } + } + }, + "Resetting your password deletes private data": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Resetting your password deletes private data" + } + } + } + }, + "Return to sign-in": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Return to sign-in" + } + } + } + }, + "Send another verification email": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Send another verification email" + } + } + } + }, + "Start the reset in this app to use your code. A code alone cannot restore the private confirmation secret.": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Start the reset in this app to use your code. A code alone cannot restore the private confirmation secret." + } + } + } + }, + "Terms": { + "comment": "Auth footer link to Terms of service.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terms" + } + } + } + }, + "The current password is incorrect.": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The current password is incorrect." + } + } + } + }, + "The request could not be completed. Check your connection and try again.": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The request could not be completed. Check your connection and try again." + } + } + } + }, + "This permanently removes your account and its saved data. You will lose access to chats and paid features. This cannot be undone.": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This permanently removes your account and its saved data. You will lose access to chats and paid features. This cannot be undone." + } + } + } + }, + "This request expired. Start again to get a new code or sign-in request.": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This request expired. Start again to get a new code or sign-in request." + } + } + } + }, + "This sign-in response could not be verified. Start sign-in again.": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This sign-in response could not be verified. Start sign-in again." + } + } + } + }, + "Try your current password or Apple, Google, or GitHub first. Resetting removes private encrypted chats and saved data.": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Try your current password or Apple, Google, or GitHub first. Resetting removes private encrypted chats and saved data." + } + } + } + }, + "Type DELETE to confirm": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Type DELETE to confirm" + } + } + } + }, + "Use at least eight characters.": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use at least eight characters." + } + } + } + }, + "Verification code": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Verification code" + } + } + } + }, + "Verify email": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Verify email" + } + } + } + }, + "Working…": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Working…" + } + } + } + }, + "Your account was deleted. Maple is clearing this account’s local data.": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your account was deleted. Maple is clearing this account’s local data." + } + } + } + }, + "Your account was not deleted because billing cleanup could not finish. Try again later or contact support.": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your account was not deleted because billing cleanup could not finish. Try again later or contact support." + } + } + } + }, + "Your email is verified.": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your email is verified." + } + } + } + }, + "Your password was changed.": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your password was changed." + } + } + } + }, + "Your password was reset. Sign in with your new password.": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your password was reset. Sign in with your new password." + } + } + } + }, + "Your sign-in changed. Reopen this screen to continue.": { + "comment": "Native account recovery, verification, or provider sign-in interface. Keep security meaning and field names explicit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your sign-in changed. Reopen this screen to continue." + } + } + } + }, + "auth.copyright": { + "comment": "Auth footer with the current Gregorian year as a string placeholder.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "© %@ Maple Privacy Labs Inc." + } + } + } + } + }, + "version": "1.0" +} diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/BrandAssets.xcassets/Contents.json b/apps/maple-ios/Sources/MapleAppUI/Resources/BrandAssets.xcassets/Contents.json new file mode 100644 index 000000000..74d6a722c --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Resources/BrandAssets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/BrandAssets.xcassets/MapleWordmark.imageset/Contents.json b/apps/maple-ios/Sources/MapleAppUI/Resources/BrandAssets.xcassets/MapleWordmark.imageset/Contents.json new file mode 100644 index 000000000..aa64ed242 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Resources/BrandAssets.xcassets/MapleWordmark.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "maple-wordmark.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/BrandAssets.xcassets/MapleWordmark.imageset/maple-wordmark.svg b/apps/maple-ios/Sources/MapleAppUI/Resources/BrandAssets.xcassets/MapleWordmark.imageset/maple-wordmark.svg new file mode 100644 index 000000000..3fe2009b2 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Resources/BrandAssets.xcassets/MapleWordmark.imageset/maple-wordmark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/FONT-LICENSE.txt b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/FONT-LICENSE.txt new file mode 100644 index 000000000..883af5b5f --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/FONT-LICENSE.txt @@ -0,0 +1,181 @@ +Copyright (c) 2009-2010, Design Science, Inc. () +Copyright (c) 2014-2018 Khan Academy (), +with Reserved Font Name KaTeX_AMS. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license available with a FAQ at: +http://scripts.sil.org/OFL + +Copyright (c) 2009-2010, Design Science, Inc. () +Copyright (c) 2014-2018 Khan Academy (), +with Reserved Font Name KaTeX_Caligraphic. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license available with a FAQ at: +http://scripts.sil.org/OFL + +Copyright (c) 2009-2010, Design Science, Inc. () +Copyright (c) 2014-2018 Khan Academy (), +with Reserved Font Name KaTeX_Fraktur. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license available with a FAQ at: +http://scripts.sil.org/OFL + +Copyright (c) 2009-2010, Design Science, Inc. () +Copyright (c) 2014-2018 Khan Academy (), +with Reserved Font Name KaTeX_Main. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license available with a FAQ at: +http://scripts.sil.org/OFL + +Copyright (c) 2009-2010, Design Science, Inc. () +Copyright (c) 2014-2018 Khan Academy (), +with Reserved Font Name KaTeX_Math. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license available with a FAQ at: +http://scripts.sil.org/OFL + +Copyright (c) 2009-2010, Design Science, Inc. () +Copyright (c) 2014-2018 Khan Academy (), +with Reserved Font Name KaTeX_SansSerif. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license available with a FAQ at: +http://scripts.sil.org/OFL + +Copyright (c) 2009-2010, Design Science, Inc. () +Copyright (c) 2014-2018 Khan Academy (), +with Reserved Font Name KaTeX_Script. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license available with a FAQ at: +http://scripts.sil.org/OFL + +Copyright (c) 2009-2010, Design Science, Inc. () +Copyright (c) 2014-2018 Khan Academy (), +with Reserved Font Name KaTeX_Size1. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license available with a FAQ at: +http://scripts.sil.org/OFL + +Copyright (c) 2009-2010, Design Science, Inc. () +Copyright (c) 2014-2018 Khan Academy (), +with Reserved Font Name KaTeX_Size2. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license available with a FAQ at: +http://scripts.sil.org/OFL + +Copyright (c) 2009-2010, Design Science, Inc. () +Copyright (c) 2014-2018 Khan Academy (), +with Reserved Font Name KaTeX_Size3. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license available with a FAQ at: +http://scripts.sil.org/OFL + +Copyright (c) 2009-2010, Design Science, Inc. () +Copyright (c) 2014-2018 Khan Academy (), +with Reserved Font Name KaTeX_Size4. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license available with a FAQ at: +http://scripts.sil.org/OFL + +Copyright (c) 2009-2010, Design Science, Inc. () +Copyright (c) 2014-2018 Khan Academy (), +with Reserved Font Name KaTeX_Typewriter. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license available with a FAQ at: +http://scripts.sil.org/OFL + +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/LICENSE b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/LICENSE new file mode 100644 index 000000000..37c6433e3 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2013-2020 Khan Academy and other contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/NOTICE.md b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/NOTICE.md new file mode 100644 index 000000000..afbbbbedf --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/NOTICE.md @@ -0,0 +1,18 @@ +# Offline equation assets + +KaTeX 0.18.10 (MIT), https://github.com/KaTeX/KaTeX/tree/v0.18.10. +`LICENSE` is the upstream license shipped with the npm distribution. +The fonts are the unmodified KaTeX fonts in that distribution. Their embedded +notices and the SIL Open Font License 1.1 are reproduced in `FONT-LICENSE.txt`. + +Unmodified minified JS, CSS and woff2 font files were extracted from the official +npm distribution, https://registry.npmjs.org/katex/-/katex-0.18.10.tgz. +Tarball SHA-512 (base64): +`/B6p9eY9DX7aHBfpkHdpirDTZ5QH9xTwL9y837PWuzuv41O5jFdqNQwVQEQsimY0/iVNkwBY4+4hMfhQ7d4Dbw==`. +Tarball SHA-256: +`ab056aae3031254cdffc64e7b886cdc43c91114b219bd7b1b68e097b8b3da6c2`. + +Only equations enter the ephemeral renderer. Markdown and transcript selection +remain native. The app allows no network, navigation, credentials, storage, +script messages, or renderer-controlled resource paths. `renderer.js`, +`renderer.css`, and `index.html` are the app-owned integration. diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_AMS-Regular.woff2 b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_AMS-Regular.woff2 new file mode 100644 index 000000000..0acaaff03 Binary files /dev/null and b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_AMS-Regular.woff2 differ diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Caligraphic-Bold.woff2 b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Caligraphic-Bold.woff2 new file mode 100644 index 000000000..f390922ec Binary files /dev/null and b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Caligraphic-Bold.woff2 differ diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Caligraphic-Regular.woff2 b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Caligraphic-Regular.woff2 new file mode 100644 index 000000000..75344a1f9 Binary files /dev/null and b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Caligraphic-Regular.woff2 differ diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Fraktur-Bold.woff2 b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Fraktur-Bold.woff2 new file mode 100644 index 000000000..395f28bea Binary files /dev/null and b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Fraktur-Bold.woff2 differ diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Fraktur-Regular.woff2 b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Fraktur-Regular.woff2 new file mode 100644 index 000000000..735f6948d Binary files /dev/null and b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Fraktur-Regular.woff2 differ diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Main-Bold.woff2 b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Main-Bold.woff2 new file mode 100644 index 000000000..ab2ad21da Binary files /dev/null and b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Main-Bold.woff2 differ diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Main-BoldItalic.woff2 b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Main-BoldItalic.woff2 new file mode 100644 index 000000000..5931794de Binary files /dev/null and b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Main-BoldItalic.woff2 differ diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Main-Italic.woff2 b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Main-Italic.woff2 new file mode 100644 index 000000000..b50920e13 Binary files /dev/null and b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Main-Italic.woff2 differ diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Main-Regular.woff2 b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Main-Regular.woff2 new file mode 100644 index 000000000..eb24a7ba2 Binary files /dev/null and b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Main-Regular.woff2 differ diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Math-BoldItalic.woff2 b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Math-BoldItalic.woff2 new file mode 100644 index 000000000..29657023a Binary files /dev/null and b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Math-BoldItalic.woff2 differ diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Math-Italic.woff2 b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Math-Italic.woff2 new file mode 100644 index 000000000..215c143fd Binary files /dev/null and b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Math-Italic.woff2 differ diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_SansSerif-Bold.woff2 b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_SansSerif-Bold.woff2 new file mode 100644 index 000000000..cfaa3bda5 Binary files /dev/null and b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_SansSerif-Bold.woff2 differ diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_SansSerif-Italic.woff2 b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_SansSerif-Italic.woff2 new file mode 100644 index 000000000..349c06dc6 Binary files /dev/null and b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_SansSerif-Italic.woff2 differ diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_SansSerif-Regular.woff2 b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_SansSerif-Regular.woff2 new file mode 100644 index 000000000..a90eea85f Binary files /dev/null and b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_SansSerif-Regular.woff2 differ diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Script-Regular.woff2 b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Script-Regular.woff2 new file mode 100644 index 000000000..b3048fc11 Binary files /dev/null and b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Script-Regular.woff2 differ diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Size1-Regular.woff2 b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Size1-Regular.woff2 new file mode 100644 index 000000000..c5a8462fb Binary files /dev/null and b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Size1-Regular.woff2 differ diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Size2-Regular.woff2 b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Size2-Regular.woff2 new file mode 100644 index 000000000..e1bccfe24 Binary files /dev/null and b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Size2-Regular.woff2 differ diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Size3-Regular.woff2 b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Size3-Regular.woff2 new file mode 100644 index 000000000..249a28662 Binary files /dev/null and b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Size3-Regular.woff2 differ diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Size4-Regular.woff2 b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Size4-Regular.woff2 new file mode 100644 index 000000000..680c13085 Binary files /dev/null and b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Size4-Regular.woff2 differ diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Typewriter-Regular.woff2 b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Typewriter-Regular.woff2 new file mode 100644 index 000000000..771f1af70 Binary files /dev/null and b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/fonts/KaTeX_Typewriter-Regular.woff2 differ diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/index.html b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/index.html new file mode 100644 index 000000000..d59782e14 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/index.html @@ -0,0 +1 @@ + diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/katex.min.css b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/katex.min.css new file mode 100644 index 000000000..303721de4 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/katex.min.css @@ -0,0 +1 @@ +@font-face{font-display:block;font-family:KaTeX_AMS;font-style:normal;font-weight:400;src:url(fonts/KaTeX_AMS-Regular.woff2) format("woff2"),url(fonts/KaTeX_AMS-Regular.woff) format("woff"),url(fonts/KaTeX_AMS-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Caligraphic;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Caligraphic-Bold.woff2) format("woff2"),url(fonts/KaTeX_Caligraphic-Bold.woff) format("woff"),url(fonts/KaTeX_Caligraphic-Bold.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Caligraphic;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Caligraphic-Regular.woff2) format("woff2"),url(fonts/KaTeX_Caligraphic-Regular.woff) format("woff"),url(fonts/KaTeX_Caligraphic-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Fraktur;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Fraktur-Bold.woff2) format("woff2"),url(fonts/KaTeX_Fraktur-Bold.woff) format("woff"),url(fonts/KaTeX_Fraktur-Bold.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Fraktur;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Fraktur-Regular.woff2) format("woff2"),url(fonts/KaTeX_Fraktur-Regular.woff) format("woff"),url(fonts/KaTeX_Fraktur-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Main-Bold.woff2) format("woff2"),url(fonts/KaTeX_Main-Bold.woff) format("woff"),url(fonts/KaTeX_Main-Bold.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:italic;font-weight:700;src:url(fonts/KaTeX_Main-BoldItalic.woff2) format("woff2"),url(fonts/KaTeX_Main-BoldItalic.woff) format("woff"),url(fonts/KaTeX_Main-BoldItalic.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:italic;font-weight:400;src:url(fonts/KaTeX_Main-Italic.woff2) format("woff2"),url(fonts/KaTeX_Main-Italic.woff) format("woff"),url(fonts/KaTeX_Main-Italic.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Main-Regular.woff2) format("woff2"),url(fonts/KaTeX_Main-Regular.woff) format("woff"),url(fonts/KaTeX_Main-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Math;font-style:italic;font-weight:700;src:url(fonts/KaTeX_Math-BoldItalic.woff2) format("woff2"),url(fonts/KaTeX_Math-BoldItalic.woff) format("woff"),url(fonts/KaTeX_Math-BoldItalic.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Math;font-style:italic;font-weight:400;src:url(fonts/KaTeX_Math-Italic.woff2) format("woff2"),url(fonts/KaTeX_Math-Italic.woff) format("woff"),url(fonts/KaTeX_Math-Italic.ttf) format("truetype")}@font-face{font-display:block;font-family:"KaTeX_SansSerif";font-style:normal;font-weight:700;src:url(fonts/KaTeX_SansSerif-Bold.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Bold.woff) format("woff"),url(fonts/KaTeX_SansSerif-Bold.ttf) format("truetype")}@font-face{font-display:block;font-family:"KaTeX_SansSerif";font-style:italic;font-weight:400;src:url(fonts/KaTeX_SansSerif-Italic.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Italic.woff) format("woff"),url(fonts/KaTeX_SansSerif-Italic.ttf) format("truetype")}@font-face{font-display:block;font-family:"KaTeX_SansSerif";font-style:normal;font-weight:400;src:url(fonts/KaTeX_SansSerif-Regular.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Regular.woff) format("woff"),url(fonts/KaTeX_SansSerif-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Script;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Script-Regular.woff2) format("woff2"),url(fonts/KaTeX_Script-Regular.woff) format("woff"),url(fonts/KaTeX_Script-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Size1;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size1-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size1-Regular.woff) format("woff"),url(fonts/KaTeX_Size1-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Size2;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size2-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size2-Regular.woff) format("woff"),url(fonts/KaTeX_Size2-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Size3;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size3-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size3-Regular.woff) format("woff"),url(fonts/KaTeX_Size3-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Size4;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size4-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size4-Regular.woff) format("woff"),url(fonts/KaTeX_Size4-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Typewriter;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Typewriter-Regular.woff2) format("woff2"),url(fonts/KaTeX_Typewriter-Regular.woff) format("woff"),url(fonts/KaTeX_Typewriter-Regular.ttf) format("truetype")}.katex{font:normal 1.21em KaTeX_Main,math,Times New Roman,serif;line-height:1.2;position:relative;text-indent:0;text-rendering:auto}.katex *{-ms-high-contrast-adjust:none!important;border-color:currentColor}.katex .katex-version:after{content:"0.18.10"}.katex .katex-mathml{border:0;-webkit-clip-path:inset(50%);clip-path:inset(50%);height:1px;overflow:hidden;padding:0;position:absolute;width:1px}.katex .katex-html>.katex-newline{display:block}.katex .katex-base{position:relative;white-space:nowrap;width:-webkit-min-content;width:-moz-min-content;width:min-content}.katex .katex-base,.katex .katex-strut{display:inline-block}.katex .textbf{font-weight:700}.katex .textit{font-style:italic}.katex .textrm{font-family:KaTeX_Main}.katex .textsf{font-family:KaTeX_SansSerif}.katex .texttt{font-family:KaTeX_Typewriter}.katex .mathnormal{font-family:KaTeX_Math;font-style:italic}.katex .mathit{font-family:KaTeX_Main;font-style:italic}.katex .mathrm{font-style:normal}.katex .mathbf{font-family:KaTeX_Main;font-weight:700}.katex .boldsymbol{font-family:KaTeX_Math;font-style:italic;font-weight:700}.katex .amsrm,.katex .mathbb,.katex .textbb{font-family:KaTeX_AMS}.katex .mathcal{font-family:KaTeX_Caligraphic}.katex .mathfrak,.katex .textfrak{font-family:KaTeX_Fraktur}.katex .mathboldfrak,.katex .textboldfrak{font-family:KaTeX_Fraktur;font-weight:700}.katex .mathtt{font-family:KaTeX_Typewriter}.katex .mathscr,.katex .textscr{font-family:KaTeX_Script}.katex .mathsf,.katex .textsf{font-family:KaTeX_SansSerif}.katex .mathboldsf,.katex .textboldsf{font-family:KaTeX_SansSerif;font-weight:700}.katex .mathitsf,.katex .mathsfit,.katex .textitsf{font-family:KaTeX_SansSerif;font-style:italic}.katex .mainrm{font-family:KaTeX_Main;font-style:normal}.katex .vlist-t{border-collapse:collapse;display:inline-table;table-layout:fixed}.katex .vlist-r{display:table-row}.katex .vlist{display:table-cell;position:relative;vertical-align:bottom}.katex .vlist>span{display:block;height:0;position:relative}.katex .vlist>span>span{display:inline-block}.katex .vlist>span>.pstrut{overflow:hidden;width:0}.katex .vlist-t2{margin-right:-2px}.katex .vlist-s{display:table-cell;font-size:1px;min-width:2px;vertical-align:bottom;width:2px}.katex .katex-vbox{align-items:baseline;display:inline-flex;flex-direction:column}.katex .katex-thinbox{display:inline-flex;flex-direction:row;max-width:0;width:0}.katex .msupsub{text-align:left}.katex .mfrac>span>span{text-align:center}.katex .mfrac .frac-line{border-bottom-style:solid;display:inline-block;width:100%}.katex .katex-hdashline,.katex .katex-hline,.katex .katex-overline .overline-line,.katex .katex-rule,.katex .katex-underline .underline-line,.katex .mfrac .frac-line{min-height:1px}.katex .mspace{display:inline-block}.katex .katex-smash{display:inline;line-height:0}.katex .clap,.katex .llap,.katex .rlap{position:relative;width:0}.katex .clap>.katex-inner,.katex .llap>.katex-inner,.katex .rlap>.katex-inner{position:absolute}.katex .clap>.katex-fix,.katex .llap>.katex-fix,.katex .rlap>.katex-fix{display:inline-block}.katex .llap>.katex-inner{right:0}.katex .clap>.katex-inner,.katex .rlap>.katex-inner{left:0}.katex .clap>.katex-inner>span{margin-left:-50%;margin-right:50%}.katex .katex-rule{border:0 solid;display:inline-block;position:relative}.katex .katex-hline,.katex .katex-overline .overline-line,.katex .katex-underline .underline-line{border-bottom-style:solid;display:inline-block;width:100%}.katex .katex-hdashline{border-bottom-style:dashed;display:inline-block;width:100%}.katex .sqrt>.katex-root{margin-left:.2777777778em;margin-right:-.5555555556em}.katex .fontsize-ensurer.reset-size1.size1,.katex .katex-sizing.reset-size1.size1{font-size:1em}.katex .fontsize-ensurer.reset-size1.size2,.katex .katex-sizing.reset-size1.size2{font-size:1.2em}.katex .fontsize-ensurer.reset-size1.size3,.katex .katex-sizing.reset-size1.size3{font-size:1.4em}.katex .fontsize-ensurer.reset-size1.size4,.katex .katex-sizing.reset-size1.size4{font-size:1.6em}.katex .fontsize-ensurer.reset-size1.size5,.katex .katex-sizing.reset-size1.size5{font-size:1.8em}.katex .fontsize-ensurer.reset-size1.size6,.katex .katex-sizing.reset-size1.size6{font-size:2em}.katex .fontsize-ensurer.reset-size1.size7,.katex .katex-sizing.reset-size1.size7{font-size:2.4em}.katex .fontsize-ensurer.reset-size1.size8,.katex .katex-sizing.reset-size1.size8{font-size:2.88em}.katex .fontsize-ensurer.reset-size1.size9,.katex .katex-sizing.reset-size1.size9{font-size:3.456em}.katex .fontsize-ensurer.reset-size1.size10,.katex .katex-sizing.reset-size1.size10{font-size:4.148em}.katex .fontsize-ensurer.reset-size1.size11,.katex .katex-sizing.reset-size1.size11{font-size:4.976em}.katex .fontsize-ensurer.reset-size2.size1,.katex .katex-sizing.reset-size2.size1{font-size:.8333333333em}.katex .fontsize-ensurer.reset-size2.size2,.katex .katex-sizing.reset-size2.size2{font-size:1em}.katex .fontsize-ensurer.reset-size2.size3,.katex .katex-sizing.reset-size2.size3{font-size:1.1666666667em}.katex .fontsize-ensurer.reset-size2.size4,.katex .katex-sizing.reset-size2.size4{font-size:1.3333333333em}.katex .fontsize-ensurer.reset-size2.size5,.katex .katex-sizing.reset-size2.size5{font-size:1.5em}.katex .fontsize-ensurer.reset-size2.size6,.katex .katex-sizing.reset-size2.size6{font-size:1.6666666667em}.katex .fontsize-ensurer.reset-size2.size7,.katex .katex-sizing.reset-size2.size7{font-size:2em}.katex .fontsize-ensurer.reset-size2.size8,.katex .katex-sizing.reset-size2.size8{font-size:2.4em}.katex .fontsize-ensurer.reset-size2.size9,.katex .katex-sizing.reset-size2.size9{font-size:2.88em}.katex .fontsize-ensurer.reset-size2.size10,.katex .katex-sizing.reset-size2.size10{font-size:3.4566666667em}.katex .fontsize-ensurer.reset-size2.size11,.katex .katex-sizing.reset-size2.size11{font-size:4.1466666667em}.katex .fontsize-ensurer.reset-size3.size1,.katex .katex-sizing.reset-size3.size1{font-size:.7142857143em}.katex .fontsize-ensurer.reset-size3.size2,.katex .katex-sizing.reset-size3.size2{font-size:.8571428571em}.katex .fontsize-ensurer.reset-size3.size3,.katex .katex-sizing.reset-size3.size3{font-size:1em}.katex .fontsize-ensurer.reset-size3.size4,.katex .katex-sizing.reset-size3.size4{font-size:1.1428571429em}.katex .fontsize-ensurer.reset-size3.size5,.katex .katex-sizing.reset-size3.size5{font-size:1.2857142857em}.katex .fontsize-ensurer.reset-size3.size6,.katex .katex-sizing.reset-size3.size6{font-size:1.4285714286em}.katex .fontsize-ensurer.reset-size3.size7,.katex .katex-sizing.reset-size3.size7{font-size:1.7142857143em}.katex .fontsize-ensurer.reset-size3.size8,.katex .katex-sizing.reset-size3.size8{font-size:2.0571428571em}.katex .fontsize-ensurer.reset-size3.size9,.katex .katex-sizing.reset-size3.size9{font-size:2.4685714286em}.katex .fontsize-ensurer.reset-size3.size10,.katex .katex-sizing.reset-size3.size10{font-size:2.9628571429em}.katex .fontsize-ensurer.reset-size3.size11,.katex .katex-sizing.reset-size3.size11{font-size:3.5542857143em}.katex .fontsize-ensurer.reset-size4.size1,.katex .katex-sizing.reset-size4.size1{font-size:.625em}.katex .fontsize-ensurer.reset-size4.size2,.katex .katex-sizing.reset-size4.size2{font-size:.75em}.katex .fontsize-ensurer.reset-size4.size3,.katex .katex-sizing.reset-size4.size3{font-size:.875em}.katex .fontsize-ensurer.reset-size4.size4,.katex .katex-sizing.reset-size4.size4{font-size:1em}.katex .fontsize-ensurer.reset-size4.size5,.katex .katex-sizing.reset-size4.size5{font-size:1.125em}.katex .fontsize-ensurer.reset-size4.size6,.katex .katex-sizing.reset-size4.size6{font-size:1.25em}.katex .fontsize-ensurer.reset-size4.size7,.katex .katex-sizing.reset-size4.size7{font-size:1.5em}.katex .fontsize-ensurer.reset-size4.size8,.katex .katex-sizing.reset-size4.size8{font-size:1.8em}.katex .fontsize-ensurer.reset-size4.size9,.katex .katex-sizing.reset-size4.size9{font-size:2.16em}.katex .fontsize-ensurer.reset-size4.size10,.katex .katex-sizing.reset-size4.size10{font-size:2.5925em}.katex .fontsize-ensurer.reset-size4.size11,.katex .katex-sizing.reset-size4.size11{font-size:3.11em}.katex .fontsize-ensurer.reset-size5.size1,.katex .katex-sizing.reset-size5.size1{font-size:.5555555556em}.katex .fontsize-ensurer.reset-size5.size2,.katex .katex-sizing.reset-size5.size2{font-size:.6666666667em}.katex .fontsize-ensurer.reset-size5.size3,.katex .katex-sizing.reset-size5.size3{font-size:.7777777778em}.katex .fontsize-ensurer.reset-size5.size4,.katex .katex-sizing.reset-size5.size4{font-size:.8888888889em}.katex .fontsize-ensurer.reset-size5.size5,.katex .katex-sizing.reset-size5.size5{font-size:1em}.katex .fontsize-ensurer.reset-size5.size6,.katex .katex-sizing.reset-size5.size6{font-size:1.1111111111em}.katex .fontsize-ensurer.reset-size5.size7,.katex .katex-sizing.reset-size5.size7{font-size:1.3333333333em}.katex .fontsize-ensurer.reset-size5.size8,.katex .katex-sizing.reset-size5.size8{font-size:1.6em}.katex .fontsize-ensurer.reset-size5.size9,.katex .katex-sizing.reset-size5.size9{font-size:1.92em}.katex .fontsize-ensurer.reset-size5.size10,.katex .katex-sizing.reset-size5.size10{font-size:2.3044444444em}.katex .fontsize-ensurer.reset-size5.size11,.katex .katex-sizing.reset-size5.size11{font-size:2.7644444444em}.katex .fontsize-ensurer.reset-size6.size1,.katex .katex-sizing.reset-size6.size1{font-size:.5em}.katex .fontsize-ensurer.reset-size6.size2,.katex .katex-sizing.reset-size6.size2{font-size:.6em}.katex .fontsize-ensurer.reset-size6.size3,.katex .katex-sizing.reset-size6.size3{font-size:.7em}.katex .fontsize-ensurer.reset-size6.size4,.katex .katex-sizing.reset-size6.size4{font-size:.8em}.katex .fontsize-ensurer.reset-size6.size5,.katex .katex-sizing.reset-size6.size5{font-size:.9em}.katex .fontsize-ensurer.reset-size6.size6,.katex .katex-sizing.reset-size6.size6{font-size:1em}.katex .fontsize-ensurer.reset-size6.size7,.katex .katex-sizing.reset-size6.size7{font-size:1.2em}.katex .fontsize-ensurer.reset-size6.size8,.katex .katex-sizing.reset-size6.size8{font-size:1.44em}.katex .fontsize-ensurer.reset-size6.size9,.katex .katex-sizing.reset-size6.size9{font-size:1.728em}.katex .fontsize-ensurer.reset-size6.size10,.katex .katex-sizing.reset-size6.size10{font-size:2.074em}.katex .fontsize-ensurer.reset-size6.size11,.katex .katex-sizing.reset-size6.size11{font-size:2.488em}.katex .fontsize-ensurer.reset-size7.size1,.katex .katex-sizing.reset-size7.size1{font-size:.4166666667em}.katex .fontsize-ensurer.reset-size7.size2,.katex .katex-sizing.reset-size7.size2{font-size:.5em}.katex .fontsize-ensurer.reset-size7.size3,.katex .katex-sizing.reset-size7.size3{font-size:.5833333333em}.katex .fontsize-ensurer.reset-size7.size4,.katex .katex-sizing.reset-size7.size4{font-size:.6666666667em}.katex .fontsize-ensurer.reset-size7.size5,.katex .katex-sizing.reset-size7.size5{font-size:.75em}.katex .fontsize-ensurer.reset-size7.size6,.katex .katex-sizing.reset-size7.size6{font-size:.8333333333em}.katex .fontsize-ensurer.reset-size7.size7,.katex .katex-sizing.reset-size7.size7{font-size:1em}.katex .fontsize-ensurer.reset-size7.size8,.katex .katex-sizing.reset-size7.size8{font-size:1.2em}.katex .fontsize-ensurer.reset-size7.size9,.katex .katex-sizing.reset-size7.size9{font-size:1.44em}.katex .fontsize-ensurer.reset-size7.size10,.katex .katex-sizing.reset-size7.size10{font-size:1.7283333333em}.katex .fontsize-ensurer.reset-size7.size11,.katex .katex-sizing.reset-size7.size11{font-size:2.0733333333em}.katex .fontsize-ensurer.reset-size8.size1,.katex .katex-sizing.reset-size8.size1{font-size:.3472222222em}.katex .fontsize-ensurer.reset-size8.size2,.katex .katex-sizing.reset-size8.size2{font-size:.4166666667em}.katex .fontsize-ensurer.reset-size8.size3,.katex .katex-sizing.reset-size8.size3{font-size:.4861111111em}.katex .fontsize-ensurer.reset-size8.size4,.katex .katex-sizing.reset-size8.size4{font-size:.5555555556em}.katex .fontsize-ensurer.reset-size8.size5,.katex .katex-sizing.reset-size8.size5{font-size:.625em}.katex .fontsize-ensurer.reset-size8.size6,.katex .katex-sizing.reset-size8.size6{font-size:.6944444444em}.katex .fontsize-ensurer.reset-size8.size7,.katex .katex-sizing.reset-size8.size7{font-size:.8333333333em}.katex .fontsize-ensurer.reset-size8.size8,.katex .katex-sizing.reset-size8.size8{font-size:1em}.katex .fontsize-ensurer.reset-size8.size9,.katex .katex-sizing.reset-size8.size9{font-size:1.2em}.katex .fontsize-ensurer.reset-size8.size10,.katex .katex-sizing.reset-size8.size10{font-size:1.4402777778em}.katex .fontsize-ensurer.reset-size8.size11,.katex .katex-sizing.reset-size8.size11{font-size:1.7277777778em}.katex .fontsize-ensurer.reset-size9.size1,.katex .katex-sizing.reset-size9.size1{font-size:.2893518519em}.katex .fontsize-ensurer.reset-size9.size2,.katex .katex-sizing.reset-size9.size2{font-size:.3472222222em}.katex .fontsize-ensurer.reset-size9.size3,.katex .katex-sizing.reset-size9.size3{font-size:.4050925926em}.katex .fontsize-ensurer.reset-size9.size4,.katex .katex-sizing.reset-size9.size4{font-size:.462962963em}.katex .fontsize-ensurer.reset-size9.size5,.katex .katex-sizing.reset-size9.size5{font-size:.5208333333em}.katex .fontsize-ensurer.reset-size9.size6,.katex .katex-sizing.reset-size9.size6{font-size:.5787037037em}.katex .fontsize-ensurer.reset-size9.size7,.katex .katex-sizing.reset-size9.size7{font-size:.6944444444em}.katex .fontsize-ensurer.reset-size9.size8,.katex .katex-sizing.reset-size9.size8{font-size:.8333333333em}.katex .fontsize-ensurer.reset-size9.size9,.katex .katex-sizing.reset-size9.size9{font-size:1em}.katex .fontsize-ensurer.reset-size9.size10,.katex .katex-sizing.reset-size9.size10{font-size:1.2002314815em}.katex .fontsize-ensurer.reset-size9.size11,.katex .katex-sizing.reset-size9.size11{font-size:1.4398148148em}.katex .fontsize-ensurer.reset-size10.size1,.katex .katex-sizing.reset-size10.size1{font-size:.2410800386em}.katex .fontsize-ensurer.reset-size10.size2,.katex .katex-sizing.reset-size10.size2{font-size:.2892960463em}.katex .fontsize-ensurer.reset-size10.size3,.katex .katex-sizing.reset-size10.size3{font-size:.337512054em}.katex .fontsize-ensurer.reset-size10.size4,.katex .katex-sizing.reset-size10.size4{font-size:.3857280617em}.katex .fontsize-ensurer.reset-size10.size5,.katex .katex-sizing.reset-size10.size5{font-size:.4339440694em}.katex .fontsize-ensurer.reset-size10.size6,.katex .katex-sizing.reset-size10.size6{font-size:.4821600771em}.katex .fontsize-ensurer.reset-size10.size7,.katex .katex-sizing.reset-size10.size7{font-size:.5785920926em}.katex .fontsize-ensurer.reset-size10.size8,.katex .katex-sizing.reset-size10.size8{font-size:.6943105111em}.katex .fontsize-ensurer.reset-size10.size9,.katex .katex-sizing.reset-size10.size9{font-size:.8331726133em}.katex .fontsize-ensurer.reset-size10.size10,.katex .katex-sizing.reset-size10.size10{font-size:1em}.katex .fontsize-ensurer.reset-size10.size11,.katex .katex-sizing.reset-size10.size11{font-size:1.1996142719em}.katex .fontsize-ensurer.reset-size11.size1,.katex .katex-sizing.reset-size11.size1{font-size:.2009646302em}.katex .fontsize-ensurer.reset-size11.size2,.katex .katex-sizing.reset-size11.size2{font-size:.2411575563em}.katex .fontsize-ensurer.reset-size11.size3,.katex .katex-sizing.reset-size11.size3{font-size:.2813504823em}.katex .fontsize-ensurer.reset-size11.size4,.katex .katex-sizing.reset-size11.size4{font-size:.3215434084em}.katex .fontsize-ensurer.reset-size11.size5,.katex .katex-sizing.reset-size11.size5{font-size:.3617363344em}.katex .fontsize-ensurer.reset-size11.size6,.katex .katex-sizing.reset-size11.size6{font-size:.4019292605em}.katex .fontsize-ensurer.reset-size11.size7,.katex .katex-sizing.reset-size11.size7{font-size:.4823151125em}.katex .fontsize-ensurer.reset-size11.size8,.katex .katex-sizing.reset-size11.size8{font-size:.578778135em}.katex .fontsize-ensurer.reset-size11.size9,.katex .katex-sizing.reset-size11.size9{font-size:.6945337621em}.katex .fontsize-ensurer.reset-size11.size10,.katex .katex-sizing.reset-size11.size10{font-size:.8336012862em}.katex .fontsize-ensurer.reset-size11.size11,.katex .katex-sizing.reset-size11.size11{font-size:1em}.katex .delimsizing.size1{font-family:KaTeX_Size1}.katex .delimsizing.size2{font-family:KaTeX_Size2}.katex .delimsizing.size3{font-family:KaTeX_Size3}.katex .delimsizing.size4{font-family:KaTeX_Size4}.katex .delimsizing.mult .delim-size1>span{font-family:KaTeX_Size1}.katex .delimsizing.mult .delim-size4>span{font-family:KaTeX_Size4}.katex .nulldelimiter{display:inline-block;width:.12em}.katex .delimcenter,.katex .op-symbol{position:relative}.katex .op-symbol.small-op{font-family:KaTeX_Size1}.katex .op-symbol.large-op{font-family:KaTeX_Size2}.katex .katex-accent>.vlist-t,.katex .op-limits>.vlist-t{text-align:center}.katex .katex-accent .accent-body{position:relative}.katex .katex-accent .accent-body:not(.accent-full){width:0}.katex .katex-overlay{display:block}.katex .mtable .vertical-separator{display:inline-block;min-width:1px}.katex .mtable .arraycolsep{display:inline-block}.katex .mtable .col-align-c>.vlist-t{text-align:center}.katex .mtable .col-align-l>.vlist-t{text-align:left}.katex .mtable .col-align-r>.vlist-t{text-align:right}.katex .svg-align{text-align:left}.katex svg{fill:currentColor;stroke:currentColor;display:block;height:inherit;position:absolute;width:100%}.katex svg path{stroke:none}.katex svg{fill-rule:nonzero;fill-opacity:1;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1}.katex img{border-style:none;max-height:none;max-width:none;min-height:0;min-width:0}.katex .katex-stretchy{display:block;overflow:hidden;position:relative;width:100%}.katex .katex-stretchy:after,.katex .katex-stretchy:before{content:""}.katex .hide-tail{overflow:hidden;position:relative;width:100%}.katex .halfarrow-left{left:0;overflow:hidden;position:absolute;width:50.2%}.katex .halfarrow-right{overflow:hidden;position:absolute;right:0;width:50.2%}.katex .brace-left{left:0;overflow:hidden;position:absolute;width:25.1%}.katex .brace-center{left:25%;overflow:hidden;position:absolute;width:50%}.katex .brace-right{overflow:hidden;position:absolute;right:0;width:25.1%}.katex .x-arrow-pad{padding:0 .5em}.katex .cd-arrow-pad{padding:0 .55556em 0 .27778em}.katex .mover,.katex .munder,.katex .x-arrow{text-align:center}.katex .boxpad{padding:0 .3em}.katex .fbox,.katex .fcolorbox{border:.04em solid;box-sizing:border-box}.katex .cancel-pad{padding:0 .2em}.katex .cancel-lap{margin-left:-.2em;margin-right:-.2em}.katex .katex-sout{border-bottom-style:solid;border-bottom-width:.08em}.katex .angl{border-right:.049em solid;border-top:.049em solid;box-sizing:border-box;margin-right:.03889em}.katex .anglpad{padding:0 .03889em}.katex .reflectbox{display:inline-block;transform:scaleX(-1)}.katex .eqn-num:before{content:"(" counter(katexEqnNo) ")";counter-increment:katexEqnNo}.katex .mml-eqn-num:before{content:"(" counter(mmlEqnNo) ")";counter-increment:mmlEqnNo}.katex .mtr-glue{width:50%}.katex .cd-vert-arrow{display:inline-block;position:relative}.katex .cd-label-left{display:inline-block;position:absolute;right:calc(50% + .3em);text-align:left}.katex .cd-label-right{display:inline-block;left:calc(50% + .3em);position:absolute;text-align:right}.katex-display{display:block;margin:1em 0;text-align:center}.katex-display>.katex{display:block;text-align:center;white-space:nowrap}.katex-display>.katex>.katex-html{display:block;position:relative}.katex-display>.katex>.katex-html>.katex-tag{position:absolute;right:0}.katex-display.leqno>.katex>.katex-html>.katex-tag{left:0;right:auto}.katex-display.fleqn>.katex{padding-left:2em;text-align:left}body{counter-reset:katexEqnNo mmlEqnNo} diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/katex.min.js b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/katex.min.js new file mode 100644 index 000000000..4254234fc --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/katex.min.js @@ -0,0 +1 @@ +!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.katex=t():e.katex=t()}("undefined"!=typeof self?self:this,function(){return function(){"use strict";var e={d:function(t,r){for(var n in r)e.o(r,n)&&!e.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:r[n]})},o:function(e,t){return Object.prototype.hasOwnProperty.call(e,t)}},t={};e.d(t,{default:function(){return po}});class r extends Error{constructor(e,t){let n,o,s="KaTeX parse error: "+e;const i=t&&t.loc;if(i&&i.start<=i.end){const e=i.lexer.input;n=i.start,o=i.end,n===e.length?s+=" at end of input: ":s+=" at position "+(n+1)+": ";const t=e.slice(n,o).replace(/[^]/g,"$&\u0332");let r,a;r=n>15?"\u2026"+e.slice(n-15,n):e.slice(0,n),a=o+15e.replace(o,"-$1").toLowerCase(),i={"&":"&",">":">","<":"<",'"':""","'":"'"},a=/[&><"']/g,l=e=>String(e).replace(a,e=>i[e]),c=e=>"ordgroup"===e.type||"color"===e.type?1===e.body.length?c(e.body[0]):e:"font"===e.type?c(e.body):e,h=new Set(["mathord","textord","atom"]),m=e=>h.has(c(e).type),u={displayMode:{type:"boolean",description:"Render math in display mode, which puts the math in display style (so \\int and \\sum are large, for example), and centers the math on the page on its own line.",cli:"-d, --display-mode"},output:{type:{enum:["htmlAndMathml","html","mathml"]},description:"Determines the markup language of the output.",cli:"-F, --format "},leqno:{type:"boolean",description:"Render display math in leqno style (left-justified tags)."},fleqn:{type:"boolean",description:"Render display math flush left."},throwOnError:{type:"boolean",default:!0,cli:"-t, --no-throw-on-error",cliDescription:"Render errors (in the color given by --error-color) instead of throwing a ParseError exception when encountering an error."},errorColor:{type:"string",default:"#cc0000",cli:"-c, --error-color ",cliDescription:"A color string given in the format 'rgb' or 'rrggbb' (no #). This option determines the color of errors rendered by the -t option.",cliProcessor:e=>"#"+e},macros:{type:"object",cli:"-m, --macro ",cliDescription:"Define custom macro of the form '\\foo:expansion' (use multiple -m arguments for multiple macros).",cliDefault:[],cliProcessor:(e,t)=>(t.push(e),t)},minRuleThickness:{type:"number",description:"Specifies a minimum thickness, in ems, for fraction lines, `\\sqrt` top lines, `{array}` vertical lines, `\\hline`, `\\hdashline`, `\\underline`, `\\overline`, and the borders of `\\fbox`, `\\boxed`, and `\\fcolorbox`.",processor:e=>Math.max(0,e),cli:"--min-rule-thickness ",cliProcessor:parseFloat},colorIsTextColor:{type:"boolean",description:"Makes \\color behave like LaTeX's 2-argument \\textcolor, instead of LaTeX's one-argument \\color mode change.",cli:"-b, --color-is-text-color"},strict:{type:[{enum:["warn","ignore","error"]},"boolean","function"],description:"Turn on strict / LaTeX faithfulness mode, which throws an error if the input uses features that are not supported by LaTeX.",cli:"-S, --strict",cliDefault:!1},trust:{type:["boolean","function"],description:"Trust the input, enabling all HTML features such as \\url.",cli:"-T, --trust"},maxSize:{type:"number",default:1/0,description:"If non-zero, all user-specified sizes, e.g. in \\rule{500em}{500em}, will be capped to maxSize ems. Otherwise, elements and spaces can be arbitrarily large",processor:e=>Math.max(0,e),cli:"-s, --max-size ",cliProcessor:parseInt},maxExpand:{type:"number",default:1e3,description:"Limit the number of macro expansions to the specified number, to prevent e.g. infinite macro loops. If set to Infinity, the macro expander will try to fully expand as in LaTeX.",processor:e=>Math.max(0,e),cli:"-e, --max-expand ",cliProcessor:e=>"Infinity"===e?1/0:parseInt(e)},globalGroup:{type:"boolean",cli:!1}};function p(e){if(Object.prototype.hasOwnProperty.call(e,"default")&&void 0!==e.default)return e.default;return function(e){if("string"!=typeof e)return e.enum[0];switch(e){case"boolean":return!1;case"string":return"";case"number":return 0;case"object":return{};default:throw new Error("Unexpected schema type; settings must declare an explicit default.")}}(Array.isArray(e.type)?e.type[0]:e.type)}function d(e,t,r,n){const o=Object.prototype.hasOwnProperty.call(r,t)?r[t]:void 0,s=Object.prototype.hasOwnProperty.call(n,"processor")?n.processor:void 0;e[t]=void 0!==o?s?s(o):o:p(n)}class g{constructor(e){void 0===e&&(e={}),this.displayMode=void 0,this.output=void 0,this.leqno=void 0,this.fleqn=void 0,this.throwOnError=void 0,this.errorColor=void 0,this.macros=void 0,this.minRuleThickness=void 0,this.colorIsTextColor=void 0,this.strict=void 0,this.trust=void 0,this.maxSize=void 0,this.maxExpand=void 0,this.globalGroup=void 0,e=e||{};for(const t of Object.keys(u)){const r=u[t];r&&d(this,t,e,r)}}reportNonstrict(e,t,r){let o=this.strict;if("function"==typeof o&&(o=o(e,t,r)),o&&"ignore"!==o){if(!0===o||"error"===o)throw new n("LaTeX-incompatible input and strict mode is set to 'error': "+t+" ["+e+"]",r);"warn"===o?"undefined"!=typeof console&&console.warn("LaTeX-incompatible input and strict mode is set to 'warn': "+t+" ["+e+"]"):"undefined"!=typeof console&&console.warn("LaTeX-incompatible input and strict mode is set to unrecognized '"+o+"': "+t+" ["+e+"]")}}useStrictBehavior(e,t,r){let n=this.strict;if("function"==typeof n)try{n=n(e,t,r)}catch(e){n="error"}return!(!n||"ignore"===n)&&(!0===n||"error"===n||("warn"===n?("undefined"!=typeof console&&console.warn("LaTeX-incompatible input and strict mode is set to 'warn': "+t+" ["+e+"]"),!1):("undefined"!=typeof console&&console.warn("LaTeX-incompatible input and strict mode is set to unrecognized '"+n+"': "+t+" ["+e+"]"),!1)))}isTrusted(e){if("url"in e&&e.url&&!e.protocol){const t=(e=>{const t=/^[\x00-\x20]*([^\\/#?]*?)(:|�*58|�*3a|&colon)/i.exec(e);return t?":"!==t[2]?null:/^[a-zA-Z][a-zA-Z0-9+\-.]*$/.test(t[1])?t[1].toLowerCase():null:"_relative"})(e.url);if(null==t)return!1;e.protocol=t}const t="function"==typeof this.trust?this.trust(e):this.trust;return Boolean(t)}}class f{constructor(e,t,r){this.id=void 0,this.size=void 0,this.cramped=void 0,this.id=e,this.size=t,this.cramped=r}sup(){return b[y[this.id]]}sub(){return b[x[this.id]]}fracNum(){return b[w[this.id]]}fracDen(){return b[v[this.id]]}cramp(){return b[k[this.id]]}text(){return b[z[this.id]]}isTight(){return this.size>=2}}const b=[new f(0,0,!1),new f(1,0,!0),new f(2,1,!1),new f(3,1,!0),new f(4,2,!1),new f(5,2,!0),new f(6,3,!1),new f(7,3,!0)],y=[4,5,4,5,6,7,6,7],x=[5,5,5,5,7,7,7,7],w=[2,3,4,5,6,7,6,7],v=[3,3,5,5,7,7,7,7],k=[1,1,3,3,5,5,7,7],z=[0,1,2,3,2,3,2,3];var S={DISPLAY:b[0],TEXT:b[2],SCRIPT:b[4],SCRIPTSCRIPT:b[6]};const M=[{name:"latin",blocks:[[256,591],[768,879]]},{name:"cyrillic",blocks:[[1024,1279]]},{name:"armenian",blocks:[[1328,1423]]},{name:"brahmic",blocks:[[2304,4255]]},{name:"georgian",blocks:[[4256,4351]]},{name:"cjk",blocks:[[12288,12543],[19968,40879],[65280,65376]]},{name:"hangul",blocks:[[44032,55215]]}];const A=[];function T(e){for(let t=0;t=A[t]&&e<=A[t+1])return!0;return!1}M.forEach(e=>e.blocks.forEach(e=>A.push(...e)));const C=e=>e+" "+e,B=80,q={doubleleftarrow:"M262 157\nl10-10c34-36 62.7-77 86-123 3.3-8 5-13.3 5-16 0-5.3-6.7-8-20-8-7.3\n 0-12.2.5-14.5 1.5-2.3 1-4.8 4.5-7.5 10.5-49.3 97.3-121.7 169.3-217 216-28\n 14-57.3 25-88 33-6.7 2-11 3.8-13 5.5-2 1.7-3 4.2-3 7.5s1 5.8 3 7.5\nc2 1.7 6.3 3.5 13 5.5 68 17.3 128.2 47.8 180.5 91.5 52.3 43.7 93.8 96.2 124.5\n 157.5 9.3 8 15.3 12.3 18 13h6c12-.7 18-4 18-10 0-2-1.7-7-5-15-23.3-46-52-87\n-86-123l-10-10h399738v-40H218c328 0 0 0 0 0l-10-8c-26.7-20-65.7-43-117-69 2.7\n-2 6-3.7 10-5 36.7-16 72.3-37.3 107-64l10-8h399782v-40z\nm8 0v40h399730v-40zm0 194v40h399730v-40z",doublerightarrow:"M399738 392l\n-10 10c-34 36-62.7 77-86 123-3.3 8-5 13.3-5 16 0 5.3 6.7 8 20 8 7.3 0 12.2-.5\n 14.5-1.5 2.3-1 4.8-4.5 7.5-10.5 49.3-97.3 121.7-169.3 217-216 28-14 57.3-25 88\n-33 6.7-2 11-3.8 13-5.5 2-1.7 3-4.2 3-7.5s-1-5.8-3-7.5c-2-1.7-6.3-3.5-13-5.5-68\n-17.3-128.2-47.8-180.5-91.5-52.3-43.7-93.8-96.2-124.5-157.5-9.3-8-15.3-12.3-18\n-13h-6c-12 .7-18 4-18 10 0 2 1.7 7 5 15 23.3 46 52 87 86 123l10 10H0v40h399782\nc-328 0 0 0 0 0l10 8c26.7 20 65.7 43 117 69-2.7 2-6 3.7-10 5-36.7 16-72.3 37.3\n-107 64l-10 8H0v40zM0 157v40h399730v-40zm0 194v40h399730v-40z",leftarrow:"M400000 241H110l3-3c68.7-52.7 113.7-120\n 135-202 4-14.7 6-23 6-25 0-7.3-7-11-21-11-8 0-13.2.8-15.5 2.5-2.3 1.7-4.2 5.8\n-5.5 12.5-1.3 4.7-2.7 10.3-4 17-12 48.7-34.8 92-68.5 130S65.3 228.3 18 247\nc-10 4-16 7.7-18 11 0 8.7 6 14.3 18 17 47.3 18.7 87.8 47 121.5 85S196 441.3 208\n 490c.7 2 1.3 5 2 9s1.2 6.7 1.5 8c.3 1.3 1 3.3 2 6s2.2 4.5 3.5 5.5c1.3 1 3.3\n 1.8 6 2.5s6 1 10 1c14 0 21-3.7 21-11 0-2-2-10.3-6-25-20-79.3-65-146.7-135-202\n l-3-3h399890zM100 241v40h399900v-40z",leftbrace:"M6 548l-6-6v-35l6-11c56-104 135.3-181.3 238-232 57.3-28.7 117\n-45 179-50h399577v120H403c-43.3 7-81 15-113 26-100.7 33-179.7 91-237 174-2.7\n 5-6 9-10 13-.7 1-7.3 1-20 1H6z",leftbraceunder:"M0 6l6-6h17c12.688 0 19.313.3 20 1 4 4 7.313 8.3 10 13\n 35.313 51.3 80.813 93.8 136.5 127.5 55.688 33.7 117.188 55.8 184.5 66.5.688\n 0 2 .3 4 1 18.688 2.7 76 4.3 172 5h399450v120H429l-6-1c-124.688-8-235-61.7\n-331-161C60.687 138.7 32.312 99.3 7 54L0 41V6z",leftgroup:"M400000 80\nH435C64 80 168.3 229.4 21 260c-5.9 1.2-18 0-18 0-2 0-3-1-3-3v-38C76 61 257 0\n 435 0h399565z",leftgroupunder:"M400000 262\nH435C64 262 168.3 112.6 21 82c-5.9-1.2-18 0-18 0-2 0-3 1-3 3v38c76 158 257 219\n 435 219h399565z",leftharpoon:"M0 267c.7 5.3 3 10 7 14h399993v-40H93c3.3\n-3.3 10.2-9.5 20.5-18.5s17.8-15.8 22.5-20.5c50.7-52 88-110.3 112-175 4-11.3 5\n-18.3 3-21-1.3-4-7.3-6-18-6-8 0-13 .7-15 2s-4.7 6.7-8 16c-42 98.7-107.3 174.7\n-196 228-6.7 4.7-10.7 8-12 10-1.3 2-2 5.7-2 11zm100-26v40h399900v-40z",leftharpoonplus:"M0 267c.7 5.3 3 10 7 14h399993v-40H93c3.3-3.3 10.2-9.5\n 20.5-18.5s17.8-15.8 22.5-20.5c50.7-52 88-110.3 112-175 4-11.3 5-18.3 3-21-1.3\n-4-7.3-6-18-6-8 0-13 .7-15 2s-4.7 6.7-8 16c-42 98.7-107.3 174.7-196 228-6.7 4.7\n-10.7 8-12 10-1.3 2-2 5.7-2 11zm100-26v40h399900v-40zM0 435v40h400000v-40z\nm0 0v40h400000v-40z",leftharpoondown:"M7 241c-4 4-6.333 8.667-7 14 0 5.333.667 9 2 11s5.333\n 5.333 12 10c90.667 54 156 130 196 228 3.333 10.667 6.333 16.333 9 17 2 .667 5\n 1 9 1h5c10.667 0 16.667-2 18-6 2-2.667 1-9.667-3-21-32-87.333-82.667-157.667\n-152-211l-3-3h399907v-40zM93 281 H400000 v-40L7 241z",leftharpoondownplus:"M7 435c-4 4-6.3 8.7-7 14 0 5.3.7 9 2 11s5.3 5.3 12\n 10c90.7 54 156 130 196 228 3.3 10.7 6.3 16.3 9 17 2 .7 5 1 9 1h5c10.7 0 16.7\n-2 18-6 2-2.7 1-9.7-3-21-32-87.3-82.7-157.7-152-211l-3-3h399907v-40H7zm93 0\nv40h399900v-40zM0 241v40h399900v-40zm0 0v40h399900v-40z",lefthook:"M400000 281 H103s-33-11.2-61-33.5S0 197.3 0 164s14.2-61.2 42.5\n-83.5C70.8 58.2 104 47 142 47 c16.7 0 25 6.7 25 20 0 12-8.7 18.7-26 20-40 3.3\n-68.7 15.7-86 37-10 12-15 25.3-15 40 0 22.7 9.8 40.7 29.5 54 19.7 13.3 43.5 21\n 71.5 23h399859zM103 281v-40h399897v40z",leftlinesegment:C("M40 281 V428 H0 V94 H40 V241 H400000 v40z"),leftbracketunder:C("M0 0 h120 V290 H399995 v120 H0z"),leftbracketover:C("M0 440 h120 V150 H399995 v-120 H0z"),leftmapsto:C("M40 281 V448H0V74H40V241H400000v40z"),leftToFrom:"M0 147h400000v40H0zm0 214c68 40 115.7 95.7 143 167h22c15.3 0 23\n-.3 23-1 0-1.3-5.3-13.7-16-37-18-35.3-41.3-69-70-101l-7-8h399905v-40H95l7-8\nc28.7-32 52-65.7 70-101 10.7-23.3 16-35.7 16-37 0-.7-7.7-1-23-1h-22C115.7 265.3\n 68 321 0 361zm0-174v-40h399900v40zm100 154v40h399900v-40z",longequal:C("M0 50 h400000 v40H0z m0 194h40000v40H0z"),midbrace:"M200428 334\nc-100.7-8.3-195.3-44-280-108-55.3-42-101.7-93-139-153l-9-14c-2.7 4-5.7 8.7-9 14\n-53.3 86.7-123.7 153-211 199-66.7 36-137.3 56.3-212 62H0V214h199568c178.3-11.7\n 311.7-78.3 403-201 6-8 9.7-12 11-12 .7-.7 6.7-1 18-1s17.3.3 18 1c1.3 0 5 4 11\n 12 44.7 59.3 101.3 106.3 170 141s145.3 54.3 229 60h199572v120z",midbraceunder:"M199572 214\nc100.7 8.3 195.3 44 280 108 55.3 42 101.7 93 139 153l9 14c2.7-4 5.7-8.7 9-14\n 53.3-86.7 123.7-153 211-199 66.7-36 137.3-56.3 212-62h199568v120H200432c-178.3\n 11.7-311.7 78.3-403 201-6 8-9.7 12-11 12-.7.7-6.7 1-18 1s-17.3-.3-18-1c-1.3 0\n-5-4-11-12-44.7-59.3-101.3-106.3-170-141s-145.3-54.3-229-60H0V214z",oiintSize1:"M512.6 71.6c272.6 0 320.3 106.8 320.3 178.2 0 70.8-47.7 177.6\n-320.3 177.6S193.1 320.6 193.1 249.8c0-71.4 46.9-178.2 319.5-178.2z\nm368.1 178.2c0-86.4-60.9-215.4-368.1-215.4-306.4 0-367.3 129-367.3 215.4 0 85.8\n60.9 214.8 367.3 214.8 307.2 0 368.1-129 368.1-214.8z",oiintSize2:"M757.8 100.1c384.7 0 451.1 137.6 451.1 230 0 91.3-66.4 228.8\n-451.1 228.8-386.3 0-452.7-137.5-452.7-228.8 0-92.4 66.4-230 452.7-230z\nm502.4 230c0-111.2-82.4-277.2-502.4-277.2s-504 166-504 277.2\nc0 110 84 276 504 276s502.4-166 502.4-276z",oiiintSize1:"M681.4 71.6c408.9 0 480.5 106.8 480.5 178.2 0 70.8-71.6 177.6\n-480.5 177.6S202.1 320.6 202.1 249.8c0-71.4 70.5-178.2 479.3-178.2z\nm525.8 178.2c0-86.4-86.8-215.4-525.7-215.4-437.9 0-524.7 129-524.7 215.4 0\n85.8 86.8 214.8 524.7 214.8 438.9 0 525.7-129 525.7-214.8z",oiiintSize2:"M1021.2 53c603.6 0 707.8 165.8 707.8 277.2 0 110-104.2 275.8\n-707.8 275.8-606 0-710.2-165.8-710.2-275.8C311 218.8 415.2 53 1021.2 53z\nm770.4 277.1c0-131.2-126.4-327.6-770.5-327.6S248.4 198.9 248.4 330.1\nc0 130 128.8 326.4 772.7 326.4s770.5-196.4 770.5-326.4z",rightarrow:"M0 241v40h399891c-47.3 35.3-84 78-110 128\n-16.7 32-27.7 63.7-33 95 0 1.3-.2 2.7-.5 4-.3 1.3-.5 2.3-.5 3 0 7.3 6.7 11 20\n 11 8 0 13.2-.8 15.5-2.5 2.3-1.7 4.2-5.5 5.5-11.5 2-13.3 5.7-27 11-41 14.7-44.7\n 39-84.5 73-119.5s73.7-60.2 119-75.5c6-2 9-5.7 9-11s-3-9-9-11c-45.3-15.3-85\n-40.5-119-75.5s-58.3-74.8-73-119.5c-4.7-14-8.3-27.3-11-40-1.3-6.7-3.2-10.8-5.5\n-12.5-2.3-1.7-7.5-2.5-15.5-2.5-14 0-21 3.7-21 11 0 2 2 10.3 6 25 20.7 83.3 67\n 151.7 139 205zm0 0v40h399900v-40z",rightbrace:"M400000 542l\n-6 6h-17c-12.7 0-19.3-.3-20-1-4-4-7.3-8.3-10-13-35.3-51.3-80.8-93.8-136.5-127.5\ns-117.2-55.8-184.5-66.5c-.7 0-2-.3-4-1-18.7-2.7-76-4.3-172-5H0V214h399571l6 1\nc124.7 8 235 61.7 331 161 31.3 33.3 59.7 72.7 85 118l7 13v35z",rightbraceunder:"M399994 0l6 6v35l-6 11c-56 104-135.3 181.3-238 232-57.3\n 28.7-117 45-179 50H-300V214h399897c43.3-7 81-15 113-26 100.7-33 179.7-91 237\n-174 2.7-5 6-9 10-13 .7-1 7.3-1 20-1h17z",rightgroup:"M0 80h399565c371 0 266.7 149.4 414 180 5.9 1.2 18 0 18 0 2 0\n 3-1 3-3v-38c-76-158-257-219-435-219H0z",rightgroupunder:"M0 262h399565c371 0 266.7-149.4 414-180 5.9-1.2 18 0 18\n 0 2 0 3 1 3 3v38c-76 158-257 219-435 219H0z",rightharpoon:"M0 241v40h399993c4.7-4.7 7-9.3 7-14 0-9.3\n-3.7-15.3-11-18-92.7-56.7-159-133.7-199-231-3.3-9.3-6-14.7-8-16-2-1.3-7-2-15-2\n-10.7 0-16.7 2-18 6-2 2.7-1 9.7 3 21 15.3 42 36.7 81.8 64 119.5 27.3 37.7 58\n 69.2 92 94.5zm0 0v40h399900v-40z",rightharpoonplus:"M0 241v40h399993c4.7-4.7 7-9.3 7-14 0-9.3-3.7-15.3-11\n-18-92.7-56.7-159-133.7-199-231-3.3-9.3-6-14.7-8-16-2-1.3-7-2-15-2-10.7 0-16.7\n 2-18 6-2 2.7-1 9.7 3 21 15.3 42 36.7 81.8 64 119.5 27.3 37.7 58 69.2 92 94.5z\nm0 0v40h399900v-40z m100 194v40h399900v-40zm0 0v40h399900v-40z",rightharpoondown:"M399747 511c0 7.3 6.7 11 20 11 8 0 13-.8 15-2.5s4.7-6.8\n 8-15.5c40-94 99.3-166.3 178-217 13.3-8 20.3-12.3 21-13 5.3-3.3 8.5-5.8 9.5\n-7.5 1-1.7 1.5-5.2 1.5-10.5s-2.3-10.3-7-15H0v40h399908c-34 25.3-64.7 57-92 95\n-27.3 38-48.7 77.7-64 119-3.3 8.7-5 14-5 16zM0 241v40h399900v-40z",rightharpoondownplus:"M399747 705c0 7.3 6.7 11 20 11 8 0 13-.8\n 15-2.5s4.7-6.8 8-15.5c40-94 99.3-166.3 178-217 13.3-8 20.3-12.3 21-13 5.3-3.3\n 8.5-5.8 9.5-7.5 1-1.7 1.5-5.2 1.5-10.5s-2.3-10.3-7-15H0v40h399908c-34 25.3\n-64.7 57-92 95-27.3 38-48.7 77.7-64 119-3.3 8.7-5 14-5 16zM0 435v40h399900v-40z\nm0-194v40h400000v-40zm0 0v40h400000v-40z",righthook:"M399859 241c-764 0 0 0 0 0 40-3.3 68.7-15.7 86-37 10-12 15-25.3\n 15-40 0-22.7-9.8-40.7-29.5-54-19.7-13.3-43.5-21-71.5-23-17.3-1.3-26-8-26-20 0\n-13.3 8.7-20 26-20 38 0 71 11.2 99 33.5 0 0 7 5.6 21 16.7 14 11.2 21 33.5 21\n 66.8s-14 61.2-42 83.5c-28 22.3-61 33.5-99 33.5L0 241z M0 281v-40h399859v40z",rightlinesegment:C("M399960 241 V94 h40 V428 h-40 V281 H0 v-40z"),rightbracketunder:C("M399995 0 h-120 V290 H0 v120 H400000z"),rightbracketover:C("M399995 440 h-120 V150 H0 v-120 H399995z"),rightToFrom:"M400000 167c-70.7-42-118-97.7-142-167h-23c-15.3 0-23 .3-23\n 1 0 1.3 5.3 13.7 16 37 18 35.3 41.3 69 70 101l7 8H0v40h399905l-7 8c-28.7 32\n-52 65.7-70 101-10.7 23.3-16 35.7-16 37 0 .7 7.7 1 23 1h23c24-69.3 71.3-125 142\n-167z M100 147v40h399900v-40zM0 341v40h399900v-40z",twoheadleftarrow:"M0 167c68 40\n 115.7 95.7 143 167h22c15.3 0 23-.3 23-1 0-1.3-5.3-13.7-16-37-18-35.3-41.3-69\n-70-101l-7-8h125l9 7c50.7 39.3 85 86 103 140h46c0-4.7-6.3-18.7-19-42-18-35.3\n-40-67.3-66-96l-9-9h399716v-40H284l9-9c26-28.7 48-60.7 66-96 12.7-23.333 19\n-37.333 19-42h-46c-18 54-52.3 100.7-103 140l-9 7H95l7-8c28.7-32 52-65.7 70-101\n 10.7-23.333 16-35.7 16-37 0-.7-7.7-1-23-1h-22C115.7 71.3 68 127 0 167z",twoheadrightarrow:"M400000 167\nc-68-40-115.7-95.7-143-167h-22c-15.3 0-23 .3-23 1 0 1.3 5.3 13.7 16 37 18 35.3\n 41.3 69 70 101l7 8h-125l-9-7c-50.7-39.3-85-86-103-140h-46c0 4.7 6.3 18.7 19 42\n 18 35.3 40 67.3 66 96l9 9H0v40h399716l-9 9c-26 28.7-48 60.7-66 96-12.7 23.333\n-19 37.333-19 42h46c18-54 52.3-100.7 103-140l9-7h125l-7 8c-28.7 32-52 65.7-70\n 101-10.7 23.333-16 35.7-16 37 0 .7 7.7 1 23 1h22c27.3-71.3 75-127 143-167z",tilde1:"M200 55.538c-77 0-168 73.953-177 73.953-3 0-7\n-2.175-9-5.437L2 97c-1-2-2-4-2-6 0-4 2-7 5-9l20-12C116 12 171 0 207 0c86 0\n 114 68 191 68 78 0 168-68 177-68 4 0 7 2 9 5l12 19c1 2.175 2 4.35 2 6.525 0\n 4.35-2 7.613-5 9.788l-19 13.05c-92 63.077-116.937 75.308-183 76.128\n-68.267.847-113-73.952-191-73.952z",tilde2:"M344 55.266c-142 0-300.638 81.316-311.5 86.418\n-8.01 3.762-22.5 10.91-23.5 5.562L1 120c-1-2-1-3-1-4 0-5 3-9 8-10l18.4-9C160.9\n 31.9 283 0 358 0c148 0 188 122 331 122s314-97 326-97c4 0 8 2 10 7l7 21.114\nc1 2.14 1 3.21 1 4.28 0 5.347-3 9.626-7 10.696l-22.3 12.622C852.6 158.372 751\n 181.476 676 181.476c-149 0-189-126.21-332-126.21z",tilde3:"M786 59C457 59 32 175.242 13 175.242c-6 0-10-3.457\n-11-10.37L.15 138c-1-7 3-12 10-13l19.2-6.4C378.4 40.7 634.3 0 804.3 0c337 0\n 411.8 157 746.8 157 328 0 754-112 773-112 5 0 10 3 11 9l1 14.075c1 8.066-.697\n 16.595-6.697 17.492l-21.052 7.31c-367.9 98.146-609.15 122.696-778.15 122.696\n -338 0-409-156.573-744-156.573z",tilde4:"M786 58C457 58 32 177.487 13 177.487c-6 0-10-3.345\n-11-10.035L.15 143c-1-7 3-12 10-13l22-6.7C381.2 35 637.15 0 807.15 0c337 0 409\n 177 744 177 328 0 754-127 773-127 5 0 10 3 11 9l1 14.794c1 7.805-3 13.38-9\n 14.495l-20.7 5.574c-366.85 99.79-607.3 139.372-776.3 139.372-338 0-409\n -175.236-744-175.236z",vec:"M377 20c0-5.333 1.833-10 5.5-14S391 0 397 0c4.667 0 8.667 1.667 12 5\n3.333 2.667 6.667 9 10 19 6.667 24.667 20.333 43.667 41 57 7.333 4.667 11\n10.667 11 18 0 6-1 10-3 12s-6.667 5-14 9c-28.667 14.667-53.667 35.667-75 63\n-1.333 1.333-3.167 3.5-5.5 6.5s-4 4.833-5 5.5c-1 .667-2.5 1.333-4.5 2s-4.333 1\n-7 1c-4.667 0-9.167-1.833-13.5-5.5S337 184 337 178c0-12.667 15.667-32.333 47-59\nH213l-171-1c-8.667-6-13-12.333-13-19 0-4.667 4.333-11.333 13-20h359\nc-16-25.333-24-45-24-59z",widehat1:"M529 0h5l519 115c5 1 9 5 9 10 0 1-1 2-1 3l-4 22\nc-1 5-5 9-11 9h-2L532 67 19 159h-2c-5 0-9-4-11-9l-5-22c-1-6 2-12 8-13z",widehat2:"M1181 0h2l1171 176c6 0 10 5 10 11l-2 23c-1 6-5 10\n-11 10h-1L1182 67 15 220h-1c-6 0-10-4-11-10l-2-23c-1-6 4-11 10-11z",widehat3:"M1181 0h2l1171 236c6 0 10 5 10 11l-2 23c-1 6-5 10\n-11 10h-1L1182 67 15 280h-1c-6 0-10-4-11-10l-2-23c-1-6 4-11 10-11z",widehat4:"M1181 0h2l1171 296c6 0 10 5 10 11l-2 23c-1 6-5 10\n-11 10h-1L1182 67 15 340h-1c-6 0-10-4-11-10l-2-23c-1-6 4-11 10-11z",widecheck1:"M529,159h5l519,-115c5,-1,9,-5,9,-10c0,-1,-1,-2,-1,-3l-4,-22c-1,\n-5,-5,-9,-11,-9h-2l-512,92l-513,-92h-2c-5,0,-9,4,-11,9l-5,22c-1,6,2,12,8,13z",widecheck2:"M1181,220h2l1171,-176c6,0,10,-5,10,-11l-2,-23c-1,-6,-5,-10,\n-11,-10h-1l-1168,153l-1167,-153h-1c-6,0,-10,4,-11,10l-2,23c-1,6,4,11,10,11z",widecheck3:"M1181,280h2l1171,-236c6,0,10,-5,10,-11l-2,-23c-1,-6,-5,-10,\n-11,-10h-1l-1168,213l-1167,-213h-1c-6,0,-10,4,-11,10l-2,23c-1,6,4,11,10,11z",widecheck4:"M1181,340h2l1171,-296c6,0,10,-5,10,-11l-2,-23c-1,-6,-5,-10,\n-11,-10h-1l-1168,273l-1167,-273h-1c-6,0,-10,4,-11,10l-2,23c-1,6,4,11,10,11z",baraboveleftarrow:"M400000 620h-399890l3 -3c68.7 -52.7 113.7 -120 135 -202\nc4 -14.7 6 -23 6 -25c0 -7.3 -7 -11 -21 -11c-8 0 -13.2 0.8 -15.5 2.5\nc-2.3 1.7 -4.2 5.8 -5.5 12.5c-1.3 4.7 -2.7 10.3 -4 17c-12 48.7 -34.8 92 -68.5 130\ns-74.2 66.3 -121.5 85c-10 4 -16 7.7 -18 11c0 8.7 6 14.3 18 17c47.3 18.7 87.8 47\n121.5 85s56.5 81.3 68.5 130c0.7 2 1.3 5 2 9s1.2 6.7 1.5 8c0.3 1.3 1 3.3 2 6\ns2.2 4.5 3.5 5.5c1.3 1 3.3 1.8 6 2.5s6 1 10 1c14 0 21 -3.7 21 -11\nc0 -2 -2 -10.3 -6 -25c-20 -79.3 -65 -146.7 -135 -202l-3 -3h399890z\nM100 620v40h399900v-40z M0 241v40h399900v-40zM0 241v40h399900v-40z",rightarrowabovebar:"M0 241v40h399891c-47.3 35.3-84 78-110 128-16.7 32\n-27.7 63.7-33 95 0 1.3-.2 2.7-.5 4-.3 1.3-.5 2.3-.5 3 0 7.3 6.7 11 20 11 8 0\n13.2-.8 15.5-2.5 2.3-1.7 4.2-5.5 5.5-11.5 2-13.3 5.7-27 11-41 14.7-44.7 39\n-84.5 73-119.5s73.7-60.2 119-75.5c6-2 9-5.7 9-11s-3-9-9-11c-45.3-15.3-85-40.5\n-119-75.5s-58.3-74.8-73-119.5c-4.7-14-8.3-27.3-11-40-1.3-6.7-3.2-10.8-5.5\n-12.5-2.3-1.7-7.5-2.5-15.5-2.5-14 0-21 3.7-21 11 0 2 2 10.3 6 25 20.7 83.3 67\n151.7 139 205zm96 379h399894v40H0zm0 0h399904v40H0z",baraboveshortleftharpoon:"M507,435c-4,4,-6.3,8.7,-7,14c0,5.3,0.7,9,2,11\nc1.3,2,5.3,5.3,12,10c90.7,54,156,130,196,228c3.3,10.7,6.3,16.3,9,17\nc2,0.7,5,1,9,1c0,0,5,0,5,0c10.7,0,16.7,-2,18,-6c2,-2.7,1,-9.7,-3,-21\nc-32,-87.3,-82.7,-157.7,-152,-211c0,0,-3,-3,-3,-3l399351,0l0,-40\nc-398570,0,-399437,0,-399437,0z M593 435 v40 H399500 v-40z\nM0 281 v-40 H399908 v40z M0 281 v-40 H399908 v40z",rightharpoonaboveshortbar:"M0,241 l0,40c399126,0,399993,0,399993,0\nc4.7,-4.7,7,-9.3,7,-14c0,-9.3,-3.7,-15.3,-11,-18c-92.7,-56.7,-159,-133.7,-199,\n-231c-3.3,-9.3,-6,-14.7,-8,-16c-2,-1.3,-7,-2,-15,-2c-10.7,0,-16.7,2,-18,6\nc-2,2.7,-1,9.7,3,21c15.3,42,36.7,81.8,64,119.5c27.3,37.7,58,69.2,92,94.5z\nM0 241 v40 H399908 v-40z M0 475 v-40 H399500 v40z M0 475 v-40 H399500 v40z",shortbaraboveleftharpoon:"M7,435c-4,4,-6.3,8.7,-7,14c0,5.3,0.7,9,2,11\nc1.3,2,5.3,5.3,12,10c90.7,54,156,130,196,228c3.3,10.7,6.3,16.3,9,17c2,0.7,5,1,9,\n1c0,0,5,0,5,0c10.7,0,16.7,-2,18,-6c2,-2.7,1,-9.7,-3,-21c-32,-87.3,-82.7,-157.7,\n-152,-211c0,0,-3,-3,-3,-3l399907,0l0,-40c-399126,0,-399993,0,-399993,0z\nM93 435 v40 H400000 v-40z M500 241 v40 H400000 v-40z M500 241 v40 H400000 v-40z",shortrightharpoonabovebar:"M53,241l0,40c398570,0,399437,0,399437,0\nc4.7,-4.7,7,-9.3,7,-14c0,-9.3,-3.7,-15.3,-11,-18c-92.7,-56.7,-159,-133.7,-199,\n-231c-3.3,-9.3,-6,-14.7,-8,-16c-2,-1.3,-7,-2,-15,-2c-10.7,0,-16.7,2,-18,6\nc-2,2.7,-1,9.7,3,21c15.3,42,36.7,81.8,64,119.5c27.3,37.7,58,69.2,92,94.5z\nM500 241 v40 H399408 v-40z M500 435 v40 H400000 v-40z"};class I{constructor(e){this.children=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.maxFontSize=void 0,this.style=void 0,this.children=e,this.classes=[],this.height=0,this.depth=0,this.maxFontSize=0,this.style={}}hasClass(e){return this.classes.includes(e)}toNode(){const e=document.createDocumentFragment();for(let t=0;t{if("toText"in e)return e.toText();throw new Error("Expected MathDomNode with toText, got "+e.constructor.name)}).join("")}}const R={pt:1,mm:7227/2540,cm:7227/254,in:72.27,bp:1.00375,pc:12,dd:1238/1157,cc:14856/1157,nd:685/642,nc:1370/107,sp:1/65536,px:1.00375},O={ex:!0,em:!0,mu:!0},E=function(e){return"string"!=typeof e&&(e=e.unit),e in R||e in O||"ex"===e},H=function(e,t){let r;if(e.unit in R)r=R[e.unit]/t.fontMetrics().ptPerEm/t.sizeMultiplier;else if("mu"===e.unit)r=t.fontMetrics().cssEmPerMu;else{let o;if(o=t.style.isTight()?t.havingStyle(t.style.text()):t,"ex"===e.unit)r=o.fontMetrics().xHeight;else{if("em"!==e.unit)throw new n("Invalid unit: '"+e.unit+"'");r=o.fontMetrics().quad}o!==t&&(r*=o.sizeMultiplier/t.sizeMultiplier)}return Math.min(e.number*r,t.maxSize)},N=function(e){return+e.toFixed(4)+"em"},D=function(e){return e.filter(e=>e).join(" ")},L=function(e){let t="";for(const r of Object.keys(e)){const n=e[r];void 0!==n&&(t+=s(r)+":"+n+";")}return t},F=function(e,t,r){if(this.classes=e||[],this.attributes={},this.height=0,this.depth=0,this.maxFontSize=0,this.style=r||{},t){t.style.isTight()&&this.classes.push("mtight");const e=t.getColor();e&&(this.style.color=e)}},P=function(e){const t=document.createElement(e);t.className=D(this.classes),Object.assign(t.style,this.style);for(const e of Object.keys(this.attributes))t.setAttribute(e,this.attributes[e]);for(let e=0;e/=\x00-\x1f]/,G=function(e){let t="<"+e;this.classes.length&&(t+=' class="'+l(D(this.classes))+'"');const r=L(this.style);r&&(t+=' style="'+l(r)+'"');for(const e of Object.keys(this.attributes)){if(V.test(e))throw new n("Invalid attribute name '"+e+"'");t+=" "+e+'="'+l(this.attributes[e])+'"'}t+=">";for(let e=0;e",t};class j{constructor(e,t,r,n){this.children=void 0,this.attributes=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.width=void 0,this.maxFontSize=void 0,this.style=void 0,this.italic=void 0,F.call(this,e,r,n),this.children=t||[]}setAttribute(e,t){this.attributes[e]=t}hasClass(e){return this.classes.includes(e)}toNode(){return P.call(this,"span")}toMarkup(){return G.call(this,"span")}}class U{constructor(e,t,r,n){this.children=void 0,this.attributes=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.maxFontSize=void 0,this.style=void 0,F.call(this,t,n),this.children=r||[],this.setAttribute("href",e)}setAttribute(e,t){this.attributes[e]=t}hasClass(e){return this.classes.includes(e)}toNode(){return P.call(this,"a")}toMarkup(){return G.call(this,"a")}}class X{constructor(e,t,r){this.src=void 0,this.alt=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.maxFontSize=void 0,this.style=void 0,this.alt=t,this.src=e,this.classes=["mord"],this.height=0,this.depth=0,this.maxFontSize=0,this.style=r}hasClass(e){return this.classes.includes(e)}toNode(){const e=document.createElement("img");return e.src=this.src,e.alt=this.alt,e.className="mord",Object.assign(e.style,this.style),e}toMarkup(){let e=''+l(this.alt)+'=n[0]&&e<=n[1])return r.name}}return null}(this.text.charCodeAt(0));l&&this.classes.push(l+"_fallback"),/[\xee\xef\xed\xec]/.test(this.text)&&(this.text=Y[this.text])}hasClass(e){return this.classes.includes(e)}toNode(){const e=document.createTextNode(this.text);let t=null;return this.italic>0&&(t=document.createElement("span"),t.style.marginRight=N(this.italic)),this.classes.length>0&&(t=t||document.createElement("span"),t.className=D(this.classes)),Object.keys(this.style).length>0&&(t=t||document.createElement("span"),Object.assign(t.style,this.style)),t?(t.appendChild(e),t):e}toMarkup(){let e=!1,t="0&&(r+="margin-right:"+N(this.italic)+";"),r+=L(this.style),r&&(e=!0,t+=' style="'+l(r)+'"');const n=l(this.text);return e?(t+=">",t+=n,t+="",t):n}}class _{constructor(e,t){this.children=void 0,this.attributes=void 0,this.children=e||[],this.attributes=t||{}}toNode(){const e=document.createElementNS("http://www.w3.org/2000/svg","svg");for(const t of Object.keys(this.attributes))e.setAttribute(t,this.attributes[t]);for(let t=0;t':''}}class Z{constructor(e){this.attributes=void 0,this.attributes=e||{}}toNode(){const e=document.createElementNS("http://www.w3.org/2000/svg","line");for(const t of Object.keys(this.attributes))e.setAttribute(t,this.attributes[t]);return e}toMarkup(){let e="","\\gt",!0),oe(se,ae,be,"\u2208","\\in",!0),oe(se,ae,be,"\ue020","\\@not"),oe(se,ae,be,"\u2282","\\subset",!0),oe(se,ae,be,"\u2283","\\supset",!0),oe(se,ae,be,"\u2286","\\subseteq",!0),oe(se,ae,be,"\u2287","\\supseteq",!0),oe(se,le,be,"\u2288","\\nsubseteq",!0),oe(se,le,be,"\u2289","\\nsupseteq",!0),oe(se,ae,be,"\u22a8","\\models"),oe(se,ae,be,"\u2190","\\leftarrow",!0),oe(se,ae,be,"\u2264","\\le"),oe(se,ae,be,"\u2264","\\leq",!0),oe(se,ae,be,"<","\\lt",!0),oe(se,ae,be,"\u2192","\\rightarrow",!0),oe(se,ae,be,"\u2192","\\to"),oe(se,le,be,"\u2271","\\ngeq",!0),oe(se,le,be,"\u2270","\\nleq",!0),oe(se,ae,ye,"\xa0","\\ "),oe(se,ae,ye,"\xa0","\\space"),oe(se,ae,ye,"\xa0","\\nobreakspace"),oe(ie,ae,ye,"\xa0","\\ "),oe(ie,ae,ye,"\xa0"," "),oe(ie,ae,ye,"\xa0","\\space"),oe(ie,ae,ye,"\xa0","\\nobreakspace"),oe(se,ae,ye,"","\\nobreak"),oe(se,ae,ye,"","\\allowbreak"),oe(se,ae,fe,",",","),oe(se,ae,fe,";",";"),oe(se,le,he,"\u22bc","\\barwedge",!0),oe(se,le,he,"\u22bb","\\veebar",!0),oe(se,ae,he,"\u2299","\\odot",!0),oe(se,ae,he,"\u2295","\\oplus",!0),oe(se,ae,he,"\u2297","\\otimes",!0),oe(se,ae,xe,"\u2202","\\partial",!0),oe(se,ae,he,"\u2298","\\oslash",!0),oe(se,le,he,"\u229a","\\circledcirc",!0),oe(se,le,he,"\u22a1","\\boxdot",!0),oe(se,ae,he,"\u25b3","\\bigtriangleup"),oe(se,ae,he,"\u25bd","\\bigtriangledown"),oe(se,ae,he,"\u2020","\\dagger"),oe(se,ae,he,"\u22c4","\\diamond"),oe(se,ae,he,"\u22c6","\\star"),oe(se,ae,he,"\u25c3","\\triangleleft"),oe(se,ae,he,"\u25b9","\\triangleright"),oe(se,ae,ge,"{","\\{"),oe(ie,ae,xe,"{","\\{"),oe(ie,ae,xe,"{","\\textbraceleft"),oe(se,ae,me,"}","\\}"),oe(ie,ae,xe,"}","\\}"),oe(ie,ae,xe,"}","\\textbraceright"),oe(se,ae,ge,"{","\\lbrace"),oe(se,ae,me,"}","\\rbrace"),oe(se,ae,ge,"[","\\lbrack",!0),oe(ie,ae,xe,"[","\\lbrack",!0),oe(se,ae,me,"]","\\rbrack",!0),oe(ie,ae,xe,"]","\\rbrack",!0),oe(se,ae,ge,"(","\\lparen",!0),oe(se,ae,me,")","\\rparen",!0),oe(ie,ae,xe,"<","\\textless",!0),oe(ie,ae,xe,">","\\textgreater",!0),oe(se,ae,ge,"\u230a","\\lfloor",!0),oe(se,ae,me,"\u230b","\\rfloor",!0),oe(se,ae,ge,"\u2308","\\lceil",!0),oe(se,ae,me,"\u2309","\\rceil",!0),oe(se,ae,xe,"\\","\\backslash"),oe(se,ae,xe,"\u2223","|"),oe(se,ae,xe,"\u2223","\\vert"),oe(ie,ae,xe,"|","\\textbar",!0),oe(se,ae,xe,"\u2225","\\|"),oe(se,ae,xe,"\u2225","\\Vert"),oe(ie,ae,xe,"\u2225","\\textbardbl"),oe(ie,ae,xe,"~","\\textasciitilde"),oe(ie,ae,xe,"\\","\\textbackslash"),oe(ie,ae,xe,"^","\\textasciicircum"),oe(se,ae,be,"\u2191","\\uparrow",!0),oe(se,ae,be,"\u21d1","\\Uparrow",!0),oe(se,ae,be,"\u2193","\\downarrow",!0),oe(se,ae,be,"\u21d3","\\Downarrow",!0),oe(se,ae,be,"\u2195","\\updownarrow",!0),oe(se,ae,be,"\u21d5","\\Updownarrow",!0),oe(se,ae,de,"\u2210","\\coprod"),oe(se,ae,de,"\u22c1","\\bigvee"),oe(se,ae,de,"\u22c0","\\bigwedge"),oe(se,ae,de,"\u2a04","\\biguplus"),oe(se,ae,de,"\u22c2","\\bigcap"),oe(se,ae,de,"\u22c3","\\bigcup"),oe(se,ae,de,"\u222b","\\int"),oe(se,ae,de,"\u222b","\\intop"),oe(se,ae,de,"\u222c","\\iint"),oe(se,ae,de,"\u222d","\\iiint"),oe(se,ae,de,"\u220f","\\prod"),oe(se,ae,de,"\u2211","\\sum"),oe(se,ae,de,"\u2a02","\\bigotimes"),oe(se,ae,de,"\u2a01","\\bigoplus"),oe(se,ae,de,"\u2a00","\\bigodot"),oe(se,ae,de,"\u222e","\\oint"),oe(se,ae,de,"\u222f","\\oiint"),oe(se,ae,de,"\u2230","\\oiiint"),oe(se,ae,de,"\u2a06","\\bigsqcup"),oe(se,ae,de,"\u222b","\\smallint"),oe(ie,ae,ue,"\u2026","\\textellipsis"),oe(se,ae,ue,"\u2026","\\mathellipsis"),oe(ie,ae,ue,"\u2026","\\ldots",!0),oe(se,ae,ue,"\u2026","\\ldots",!0),oe(se,ae,ue,"\u22ef","\\@cdots",!0),oe(se,ae,ue,"\u22f1","\\ddots",!0),oe(se,ae,xe,"\u22ee","\\varvdots"),oe(ie,ae,xe,"\u22ee","\\varvdots"),oe(se,ae,ce,"\u02ca","\\acute"),oe(se,ae,ce,"\u02cb","\\grave"),oe(se,ae,ce,"\xa8","\\ddot"),oe(se,ae,ce,"~","\\tilde"),oe(se,ae,ce,"\u02c9","\\bar"),oe(se,ae,ce,"\u02d8","\\breve"),oe(se,ae,ce,"\u02c7","\\check"),oe(se,ae,ce,"^","\\hat"),oe(se,ae,ce,"\u20d7","\\vec"),oe(se,ae,ce,"\u02d9","\\dot"),oe(se,ae,ce,"\u02da","\\mathring"),oe(se,ae,pe,"\ue131","\\@imath"),oe(se,ae,pe,"\ue237","\\@jmath"),oe(se,ae,xe,"\u0131","\u0131"),oe(se,ae,xe,"\u0237","\u0237"),oe(ie,ae,xe,"\u0131","\\i",!0),oe(ie,ae,xe,"\u0237","\\j",!0),oe(ie,ae,xe,"\xdf","\\ss",!0),oe(ie,ae,xe,"\xe6","\\ae",!0),oe(ie,ae,xe,"\u0153","\\oe",!0),oe(ie,ae,xe,"\xf8","\\o",!0),oe(ie,ae,xe,"\xc6","\\AE",!0),oe(ie,ae,xe,"\u0152","\\OE",!0),oe(ie,ae,xe,"\xd8","\\O",!0),oe(ie,ae,ce,"\u02ca","\\'"),oe(ie,ae,ce,"\u02cb","\\`"),oe(ie,ae,ce,"\u02c6","\\^"),oe(ie,ae,ce,"\u02dc","\\~"),oe(ie,ae,ce,"\u02c9","\\="),oe(ie,ae,ce,"\u02d8","\\u"),oe(ie,ae,ce,"\u02d9","\\."),oe(ie,ae,ce,"\xb8","\\c"),oe(ie,ae,ce,"\u02da","\\r"),oe(ie,ae,ce,"\u02c7","\\v"),oe(ie,ae,ce,"\xa8",'\\"'),oe(ie,ae,ce,"\u02dd","\\H"),oe(ie,ae,ce,"\u25ef","\\textcircled");const we={"--":!0,"---":!0,"``":!0,"''":!0};oe(ie,ae,xe,"\u2013","--",!0),oe(ie,ae,xe,"\u2013","\\textendash"),oe(ie,ae,xe,"\u2014","---",!0),oe(ie,ae,xe,"\u2014","\\textemdash"),oe(ie,ae,xe,"\u2018","`",!0),oe(ie,ae,xe,"\u2018","\\textquoteleft"),oe(ie,ae,xe,"\u2019","'",!0),oe(ie,ae,xe,"\u2019","\\textquoteright"),oe(ie,ae,xe,"\u201c","``",!0),oe(ie,ae,xe,"\u201c","\\textquotedblleft"),oe(ie,ae,xe,"\u201d","''",!0),oe(ie,ae,xe,"\u201d","\\textquotedblright"),oe(se,ae,xe,"\xb0","\\degree",!0),oe(ie,ae,xe,"\xb0","\\degree"),oe(ie,ae,xe,"\xb0","\\textdegree",!0),oe(se,ae,xe,"\xa3","\\pounds"),oe(se,ae,xe,"\xa3","\\mathsterling",!0),oe(ie,ae,xe,"\xa3","\\pounds"),oe(ie,ae,xe,"\xa3","\\textsterling",!0),oe(se,le,xe,"\u2720","\\maltese"),oe(ie,le,xe,"\u2720","\\maltese");const ve='0123456789/@."';for(let e=0;e<14;e++){const t=ve.charAt(e);oe(se,ae,xe,t,t)}const ke='0123456789!@*()-=+";:?/.,';for(let e=0;e<25;e++){const t=ke.charAt(e);oe(ie,ae,xe,t,t)}const ze="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";for(let e=0;e<52;e++){const t=ze.charAt(e);oe(se,ae,pe,t,t),oe(ie,ae,xe,t,t)}let Se;oe(se,le,xe,"C","\u2102"),oe(ie,le,xe,"C","\u2102"),oe(se,le,xe,"H","\u210d"),oe(ie,le,xe,"H","\u210d"),oe(se,le,xe,"N","\u2115"),oe(ie,le,xe,"N","\u2115"),oe(se,le,xe,"P","\u2119"),oe(ie,le,xe,"P","\u2119"),oe(se,le,xe,"Q","\u211a"),oe(ie,le,xe,"Q","\u211a"),oe(se,le,xe,"R","\u211d"),oe(ie,le,xe,"R","\u211d"),oe(se,le,xe,"Z","\u2124"),oe(ie,le,xe,"Z","\u2124"),oe(se,ae,pe,"h","\u210e"),oe(ie,ae,pe,"h","\u210e");for(let e=0;e<52;e++){const t=ze.charAt(e);Se=String.fromCharCode(55349,56320+e),oe(se,ae,pe,t,Se),oe(ie,ae,xe,t,Se),Se=String.fromCharCode(55349,56372+e),oe(se,ae,pe,t,Se),oe(ie,ae,xe,t,Se),Se=String.fromCharCode(55349,56424+e),oe(se,ae,pe,t,Se),oe(ie,ae,xe,t,Se),Se=String.fromCharCode(55349,56580+e),oe(se,ae,pe,t,Se),oe(ie,ae,xe,t,Se),Se=String.fromCharCode(55349,56684+e),oe(se,ae,pe,t,Se),oe(ie,ae,xe,t,Se),Se=String.fromCharCode(55349,56736+e),oe(se,ae,pe,t,Se),oe(ie,ae,xe,t,Se),Se=String.fromCharCode(55349,56788+e),oe(se,ae,pe,t,Se),oe(ie,ae,xe,t,Se),Se=String.fromCharCode(55349,56840+e),oe(se,ae,pe,t,Se),oe(ie,ae,xe,t,Se),Se=String.fromCharCode(55349,56944+e),oe(se,ae,pe,t,Se),oe(ie,ae,xe,t,Se),e<26&&(Se=String.fromCharCode(55349,56632+e),oe(se,ae,pe,t,Se),oe(ie,ae,xe,t,Se),Se=String.fromCharCode(55349,56476+e),oe(se,ae,pe,t,Se),oe(ie,ae,xe,t,Se))}Se=String.fromCharCode(55349,56668),oe(se,ae,pe,"k",Se),oe(ie,ae,xe,"k",Se);for(let e=0;e<10;e++){const t=e.toString();Se=String.fromCharCode(55349,57294+e),oe(se,ae,pe,t,Se),oe(ie,ae,xe,t,Se),Se=String.fromCharCode(55349,57314+e),oe(se,ae,pe,t,Se),oe(ie,ae,xe,t,Se),Se=String.fromCharCode(55349,57324+e),oe(se,ae,pe,t,Se),oe(ie,ae,xe,t,Se),Se=String.fromCharCode(55349,57334+e),oe(se,ae,pe,t,Se),oe(ie,ae,xe,t,Se)}const Me="\xd0\xde\xfe";for(let e=0;e<3;e++){const t=Me.charAt(e);oe(se,ae,pe,t,t),oe(ie,ae,xe,t,t)}const Ae={mathClass:"mathbf",textClass:"textbf",font:"Main-Bold"},Te={mathClass:"mathnormal",textClass:"textit",font:"Math-Italic"},Ce={mathClass:"boldsymbol",textClass:"boldsymbol",font:"Main-BoldItalic"},Be={mathClass:"",textClass:"",font:""},qe={mathClass:"mathfrak",textClass:"textfrak",font:"Fraktur-Regular"},Ie={mathClass:"mathbb",textClass:"textbb",font:"AMS-Regular"},Re={mathClass:"mathboldfrak",textClass:"textboldfrak",font:"Fraktur-Regular"},Oe={mathClass:"mathsf",textClass:"textsf",font:"SansSerif-Regular"},Ee={mathClass:"mathboldsf",textClass:"textboldsf",font:"SansSerif-Bold"},He={mathClass:"mathitsf",textClass:"textitsf",font:"SansSerif-Italic"},Ne={mathClass:"mathtt",textClass:"texttt",font:"Typewriter-Regular"},De=[Ae,Ae,Te,Te,Ce,Ce,{mathClass:"mathscr",textClass:"textscr",font:"Script-Regular"},Be,Be,Be,qe,qe,Ie,Ie,Re,Re,Oe,Oe,Ee,Ee,He,He,Be,Be,Ne,Ne],Le=[Ae,Be,Oe,Ee,Ne],Fe=function(e,t,r){if(ne[r][e]){const t=ne[r][e].replace;t&&(e=t)}return{value:e,metrics:ee(e,t,r)}},Pe=function(e,t,r,n,o){const s=Fe(e,t,r),i=s.metrics;let a;if(e=s.value,i){let t=i.italic;("text"===r||n&&"mathit"===n.font)&&(t=0),a=new W(e,i.height,i.depth,t,i.skew,i.width,o)}else"undefined"!=typeof console&&console.warn("No character metrics for '"+e+"' in style '"+t+"' and mode '"+r+"'"),a=new W(e,0,0,0,0,0,o);if(n){a.maxFontSize=n.sizeMultiplier,n.style.isTight()&&a.classes.push("mtight");const e=n.getColor();e&&(a.style.color=e)}return a},Ve=function(e,t,r,n){return void 0===n&&(n=[]),"boldsymbol"===r.font&&Fe(e,"Main-Bold",t).metrics?Pe(e,"Main-Bold",t,r,n.concat(["mathbf"])):"\\"===e||"main"===ne[t][e].font?Pe(e,"Main-Regular",t,r,n):Pe(e,"AMS-Regular",t,r,n.concat(["amsrm"]))},Ge=function(e,t){const r="mathord"===e.type?"mathord":"textord",o=e.mode,s=e.text,i=["mord"],a=t.font,l=t.fontFamily,c=t.fontWeight,h=t.fontShape,m="math"===o||"text"===o&&!!a,u=m?a:l;let p="",d="";if(55349===s.charCodeAt(0)){const e=(e=>{const t=1024*(e.charCodeAt(0)-55296)+(e.charCodeAt(1)-56320)+65536;if(119808<=t&&t<120484){const e=Math.floor((t-119808)/26);return De[e]}if(120782<=t&&t<=120831){const e=Math.floor((t-120782)/10);return Le[e]}if(120485===t||120486===t)return De[0];if(120486{if(D(e.classes)!==D(t.classes)||e.skew!==t.skew||e.maxFontSize!==t.maxFontSize||0!==e.italic&&e.hasClass("mathnormal"))return!1;if(1===e.classes.length){const t=e.classes[0];if("mbin"===t||"mord"===t)return!1}for(const r of Object.keys(e.style))if(e.style[r]!==t.style[r])return!1;for(const r of Object.keys(t.style))if(e.style[r]!==t.style[r])return!1;return!0},Ue=e=>{for(let t=0;tt&&(t=s.height),s.depth>r&&(r=s.depth),s.maxFontSize>n&&(n=s.maxFontSize)}e.height=t,e.depth=r,e.maxFontSize=n},Ye=function(e,t,r,n){const o=new j(e,t,r,n);return Xe(o),o},We=(e,t,r,n)=>new j(e,t,r,n),_e=function(e,t,r){const n=Ye([e],[],t);return n.height=Math.max(r||t.fontMetrics().defaultRuleThickness,t.minRuleThickness),n.style.borderBottomWidth=N(n.height),n.maxFontSize=1,n},$e=function(e){const t=new I(e);return Xe(t),t},Ze=function(e,t){return e instanceof I?Ye([],[e],t):e},Ke=function(e,t){const r=function(e){if("individualShift"===e.positionType){const t=e.children,r=[t[0]],n=-t[0].shift-t[0].elem.depth;let o=n;for(let e=1;e{const r=Ye(["mspace"],[],t),n=H(e,t);return r.style.marginRight=N(n),r},Qe=(e,t,r)=>{let n,o;switch(e){case"amsrm":n="AMS";break;case"textrm":n="Main";break;case"textsf":n="SansSerif";break;case"texttt":n="Typewriter";break;default:n=e}return o="textbf"===t&&"textit"===r?"BoldItalic":"textbf"===t?"Bold":"textit"===r?"Italic":"Regular",n+"-"+o},et={mathbf:{variant:"bold",fontName:"Main-Bold"},mathrm:{variant:"normal",fontName:"Main-Regular"},textit:{variant:"italic",fontName:"Main-Italic"},mathit:{variant:"italic",fontName:"Main-Italic"},mathnormal:{variant:"italic",fontName:"Math-Italic"},mathsfit:{variant:"sans-serif-italic",fontName:"SansSerif-Italic"},mathbb:{variant:"double-struck",fontName:"AMS-Regular"},mathcal:{variant:"script",fontName:"Caligraphic-Regular"},mathfrak:{variant:"fraktur",fontName:"Fraktur-Regular"},mathscr:{variant:"script",fontName:"Script-Regular"},mathsf:{variant:"sans-serif",fontName:"SansSerif-Regular"},mathtt:{variant:"monospace",fontName:"Typewriter-Regular"}},tt={vec:["vec",.471,.714],oiintSize1:["oiintSize1",.957,.499],oiintSize2:["oiintSize2",1.472,.659],oiiintSize1:["oiiintSize1",1.304,.499],oiiintSize2:["oiiintSize2",1.98,.659]},rt=function(e,t){const r=tt[e],n=r[0],o=r[1],s=r[2],i=new $(n),a=new _([i],{width:N(o),height:N(s),style:"width:"+N(o),viewBox:"0 0 "+1e3*o+" "+1e3*s,preserveAspectRatio:"xMinYMin"}),l=We(["katex-overlay"],[a],t);return l.height=s,l.style.height=N(s),l.style.width=N(o),l},nt={number:3,unit:"mu"},ot={number:4,unit:"mu"},st={number:5,unit:"mu"},it={mord:{mop:nt,mbin:ot,mrel:st,minner:nt},mop:{mord:nt,mop:nt,mrel:st,minner:nt},mbin:{mord:ot,mop:ot,mopen:ot,minner:ot},mrel:{mord:st,mop:st,mopen:st,minner:st},mopen:{},mclose:{mop:nt,mbin:ot,mrel:st,minner:nt},mpunct:{mord:nt,mop:nt,mrel:st,mopen:nt,mclose:nt,mpunct:nt,minner:nt},minner:{mord:nt,mop:nt,mbin:ot,mrel:st,mopen:nt,mpunct:nt,minner:nt}},at={mord:{mop:nt},mop:{mord:nt,mop:nt},mbin:{},mrel:{},mopen:{},mclose:{mop:nt},mpunct:{},minner:{mop:nt}},lt={},ct={},ht={};function mt(e){const t=e.type,r=e.names,n=e.htmlBuilder,o=e.mathmlBuilder;for(let t=0;t{const r=t.classes[0],n=e.classes[0];"mbin"===r&&ft.has(n)?t.classes[0]="mord":"mbin"===n&>.has(r)&&(e.classes[0]="mord")},{node:i},a,l),wt(o,(e,t)=>{var r,n;const o=zt(t),i=zt(e),a=o&&i?e.hasClass("mtight")?null==(r=at[o])?void 0:r[i]:null==(n=it[o])?void 0:n[i]:null;if(a)return Je(a,s)},{node:i},a,l),o},wt=function(e,t,r,n,o){n&&e.push(n);let s=0;for(;sr=>{e.splice(t+1,0,r),s++})(s)}n&&e.pop()},vt=function(e){return e instanceof I||e instanceof U||e instanceof j&&e.hasClass("enclosing")?e:null},kt=function(e,t){const r=vt(e);if(r){const e=r.children;if(e.length){if("right"===t)return kt(e[e.length-1],"right");if("left"===t)return kt(e[0],"left")}}return e},zt=function(e,t){if(!e)return null;t&&(e=kt(e,t));const r=e.classes[0];return yt[r]||null},St=function(e,t){const r=["nulldelimiter"].concat(e.baseSizingClasses());return Ye(t.concat(r))},Mt=function(e,t,r){if(!e)return Ye();if(ct[e.type]){let n=ct[e.type](e,t);if(r&&t.size!==r.size){n=Ye(t.sizingClasses(r),[n],t);const e=t.sizeMultiplier/r.sizeMultiplier;n.height*=e,n.depth*=e}return n}throw new n("Got group of unknown type: '"+e.type+"'")};function At(e,t){const r=Ye(["katex-base"],e,t),n=Ye(["katex-strut"]);return n.style.height=N(r.height+r.depth),r.depth&&(n.style.verticalAlign=N(-r.depth)),r.children.unshift(n),r}function Tt(e,t){let r=null;1===e.length&&"tag"===e[0].type&&(r=e[0].tag,e=e[0].body);const n=xt(e,t,"root");let o;2===n.length&&n[1].hasClass("katex-tag")&&(o=n.pop());const s=[];let i,a=[];for(let e=0;e0&&(s.push(At(a,t)),a=[]),s.push(n[e]));a.length>0&&s.push(At(a,t)),r?(i=At(xt(r,t,!0),t),i.classes=["katex-tag"],s.push(i)):o&&s.push(o);const l=Ye(["katex-html"],s);if(l.setAttribute("aria-hidden","true"),i){const e=i.children[0];e.style.height=N(l.height+l.depth),l.depth&&(e.style.verticalAlign=N(-l.depth))}return l}function Ct(e){return new I(e)}class Bt{constructor(e,t,r){this.type=void 0,this.attributes=void 0,this.children=void 0,this.classes=void 0,this.type=e,this.attributes={},this.children=t||[],this.classes=r||[]}setAttribute(e,t){this.attributes[e]=t}getAttribute(e){return this.attributes[e]}toNode(){const e=document.createElementNS("http://www.w3.org/1998/Math/MathML",this.type);for(const t of Object.entries(this.attributes)){const r=t[0],n=t[1];e.setAttribute(r,n)}this.classes.length>0&&(e.className=D(this.classes));for(let t=0;t0&&(e+=' class ="'+l(D(this.classes))+'"'),e+=">";for(let t=0;t",e}toText(){return this.children.map(e=>e.toText()).join("")}}class qt{constructor(e){this.text=void 0,this.text=e}toNode(){return document.createTextNode(this.text)}toMarkup(){return l(this.toText())}toText(){return this.text}}class It{constructor(e){this.width=void 0,this.character=void 0,this.width=e,this.character=e>=.05555&&e<=.05556?"\u200a":e>=.1666&&e<=.1667?"\u2009":e>=.2222&&e<=.2223?"\u2005":e>=.2777&&e<=.2778?"\u2005\u200a":e>=-.05556&&e<=-.05555?"\u200a\u2063":e>=-.1667&&e<=-.1666?"\u2009\u2063":e>=-.2223&&e<=-.2222?"\u205f\u2063":e>=-.2778&&e<=-.2777?"\u2005\u2063":null}toNode(){if(this.character)return document.createTextNode(this.character);{const e=document.createElementNS("http://www.w3.org/1998/Math/MathML","mspace");return e.setAttribute("width",N(this.width)),e}}toMarkup(){return this.character?""+this.character+"":''}toText(){return this.character?this.character:" "}}const Rt=new Set(["\\imath","\\jmath"]),Ot=new Set(["mrow","mtable"]),Et=function(e,t,r){var n,o;return ne[t][e]&&ne[t][e].replace&&55349!==e.charCodeAt(0)&&(!Object.prototype.hasOwnProperty.call(we,e)||"tt"!==(null==r||null==(n=r.fontFamily)?void 0:n.slice(4,6))&&"tt"!==(null==r||null==(o=r.font)?void 0:o.slice(4,6)))&&(e=ne[t][e].replace),new qt(e)},Ht=function(e){return 1===e.length?e[0]:new Bt("mrow",e)},Nt={mathit:"italic",boldsymbol:e=>"textord"===e.type?"bold":"bold-italic",mathbf:"bold",mathbb:"double-struck",mathsfit:"sans-serif-italic",mathfrak:"fraktur",mathscr:"script",mathcal:"script",mathsf:"sans-serif",mathtt:"monospace"},Dt=(e,t)=>{if("text"===e.mode){if("texttt"===t.fontFamily)return"monospace";if("textsf"===t.fontFamily)return"textit"===t.fontShape&&"textbf"===t.fontWeight?"sans-serif-bold-italic":"textit"===t.fontShape?"sans-serif-italic":"textbf"===t.fontWeight?"bold-sans-serif":"sans-serif";if("textit"===t.fontShape&&"textbf"===t.fontWeight)return"bold-italic";if("textit"===t.fontShape)return"italic";if("textbf"===t.fontWeight)return"bold"}const r=t.font;if(!r||"mathnormal"===r)return null;const n=e.mode,o=Nt[r];if(o)return"function"==typeof o?o(e):o;let s=e.text;if(Rt.has(s))return null;if(ne[n][s]){const e=ne[n][s].replace;e&&(s=e)}return ee(s,et[r].fontName,n)?et[r].variant:null};function Lt(e){if(!e)return!1;if("mi"===e.type&&1===e.children.length){const t=e.children[0];return t instanceof qt&&"."===t.text}if("mo"===e.type&&1===e.children.length&&"true"===e.getAttribute("separator")&&"0em"===e.getAttribute("lspace")&&"0em"===e.getAttribute("rspace")){const t=e.children[0];return t instanceof qt&&","===t.text}return!1}const Ft=function(e,t,r){if(1===e.length){const n=Vt(e[0],t);return r&&n instanceof Bt&&"mo"===n.type&&(n.setAttribute("lspace","0em"),n.setAttribute("rspace","0em")),[n]}const n=[];let o;for(let r=0;r=1&&("mn"===o.type||Lt(o))){const e=s.children[0];e instanceof Bt&&"mn"===e.type&&(e.children=[...o.children,...e.children],n.pop())}else if("mi"===o.type&&1===o.children.length){const e=o.children[0];if(e instanceof qt&&"\u0338"===e.text&&("mo"===s.type||"mi"===s.type||"mn"===s.type)){const e=s.children[0];e instanceof qt&&e.text.length>0&&(e.text=e.text.slice(0,1)+"\u0338"+e.text.slice(1),n.pop())}}}n.push(s),o=s}return n},Pt=function(e,t,r){return Ht(Ft(e,t,r))},Vt=function(e,t){if(!e)return new Bt("mrow");if(ht[e.type])return ht[e.type](e,t);throw new n("Got group of unknown type: '"+e.type+"'")};function Gt(e,t,r,n,o){const s=Ft(e,r);let i;i=1===s.length&&s[0]instanceof Bt&&Ot.has(s[0].type)?s[0]:new Bt("mrow",s);const a=new Bt("annotation",[new qt(t)]);a.setAttribute("encoding","application/x-tex");const l=new Bt("semantics",[i,a]),c=new Bt("math",[l]);c.setAttribute("xmlns","http://www.w3.org/1998/Math/MathML"),n&&c.setAttribute("display","block");return Ye([o?"katex":"katex-mathml"],[c])}const jt=[[1,1,1],[2,1,1],[3,1,1],[4,2,1],[5,2,1],[6,3,1],[7,4,2],[8,6,3],[9,7,6],[10,8,7],[11,10,9]],Ut=[.5,.6,.7,.8,.9,1,1.2,1.44,1.728,2.074,2.488],Xt=function(e,t){return t.size<2?e:jt[e-1][t.size-1]};class Yt{constructor(e){this.style=void 0,this.color=void 0,this.size=void 0,this.textSize=void 0,this.phantom=void 0,this.font=void 0,this.fontFamily=void 0,this.fontWeight=void 0,this.fontShape=void 0,this.sizeMultiplier=void 0,this.maxSize=void 0,this.minRuleThickness=void 0,this._fontMetrics=void 0,this.style=e.style,this.color=e.color,this.size=e.size||Yt.BASESIZE,this.textSize=e.textSize||this.size,this.phantom=!!e.phantom,this.font=e.font||"",this.fontFamily=e.fontFamily||"",this.fontWeight=e.fontWeight||"",this.fontShape=e.fontShape||"",this.sizeMultiplier=Ut[this.size-1],this.maxSize=e.maxSize,this.minRuleThickness=e.minRuleThickness,this._fontMetrics=void 0}extend(e){const t={style:this.style,size:this.size,textSize:this.textSize,color:this.color,phantom:this.phantom,font:this.font,fontFamily:this.fontFamily,fontWeight:this.fontWeight,fontShape:this.fontShape,maxSize:this.maxSize,minRuleThickness:this.minRuleThickness};return Object.assign(t,e),new Yt(t)}havingStyle(e){return this.style===e?this:this.extend({style:e,size:Xt(this.textSize,e)})}havingCrampedStyle(){return this.havingStyle(this.style.cramp())}havingSize(e){return this.size===e&&this.textSize===e?this:this.extend({style:this.style.text(),size:e,textSize:e,sizeMultiplier:Ut[e-1]})}havingBaseStyle(e){e=e||this.style.text();const t=Xt(Yt.BASESIZE,e);return this.size===t&&this.textSize===Yt.BASESIZE&&this.style===e?this:this.extend({style:e,size:t})}havingBaseSizing(){let e;switch(this.style.id){case 4:case 5:e=3;break;case 6:case 7:e=1;break;default:e=6}return this.extend({style:this.style.text(),size:e})}withColor(e){return this.extend({color:e})}withPhantom(){return this.extend({phantom:!0})}withFont(e){return this.extend({font:e})}withTextFontFamily(e){return this.extend({fontFamily:e,font:""})}withTextFontWeight(e){return this.extend({fontWeight:e,font:""})}withTextFontShape(e){return this.extend({fontShape:e,font:""})}sizingClasses(e){return e.size!==this.size?["katex-sizing","reset-size"+e.size,"size"+this.size]:[]}baseSizingClasses(){return this.size!==Yt.BASESIZE?["katex-sizing","reset-size"+this.size,"size"+Yt.BASESIZE]:[]}fontMetrics(){return this._fontMetrics||(this._fontMetrics=function(e){let t;if(t=e>=5?0:e>=3?1:2,!te[t]){const e=te[t]={cssEmPerMu:J.quad[t]/18};for(const r of Object.keys(J))e[r]=J[r][t]}return te[t]}(this.size)),this._fontMetrics}getColor(){return this.phantom?"transparent":this.color}}Yt.BASESIZE=6;var Wt=Yt;const _t=function(e){return new Wt({style:e.displayMode?S.DISPLAY:S.TEXT,maxSize:e.maxSize,minRuleThickness:e.minRuleThickness})},$t=function(e,t){if(t.displayMode){const r=["katex-display"];t.leqno&&r.push("leqno"),t.fleqn&&r.push("fleqn"),e=Ye(r,[e])}return e},Zt=function(e,t,r){const n=_t(r);let o;if("mathml"===r.output)return Gt(e,t,n,r.displayMode,!0);if("html"===r.output){const t=Tt(e,n);o=Ye(["katex"],[t])}else{const s=Gt(e,t,n,r.displayMode,!1),i=Tt(e,n);o=Ye(["katex"],[s,i])}return $t(o,r)};const Kt={widehat:"^",widecheck:"\u02c7",widetilde:"~",utilde:"~",overleftarrow:"\u2190",underleftarrow:"\u2190",xleftarrow:"\u2190",overrightarrow:"\u2192",underrightarrow:"\u2192",xrightarrow:"\u2192",underbrace:"\u23df",overbrace:"\u23de",underbracket:"\u23b5",overbracket:"\u23b4",overgroup:"\u23e0",undergroup:"\u23e1",overleftrightarrow:"\u2194",underleftrightarrow:"\u2194",xleftrightarrow:"\u2194",Overrightarrow:"\u21d2",xRightarrow:"\u21d2",overleftharpoon:"\u21bc",xleftharpoonup:"\u21bc",overrightharpoon:"\u21c0",xrightharpoonup:"\u21c0",xLeftarrow:"\u21d0",xLeftrightarrow:"\u21d4",xhookleftarrow:"\u21a9",xhookrightarrow:"\u21aa",xmapsto:"\u21a6",xrightharpoondown:"\u21c1",xleftharpoondown:"\u21bd",xrightleftharpoons:"\u21cc",xleftrightharpoons:"\u21cb",xtwoheadleftarrow:"\u219e",xtwoheadrightarrow:"\u21a0",xlongequal:"=",xtofrom:"\u21c4",xrightleftarrows:"\u21c4",xrightequilibrium:"\u21cc",xleftequilibrium:"\u21cb","\\cdrightarrow":"\u2192","\\cdleftarrow":"\u2190","\\cdlongequal":"="},Jt=function(e){const t=new Bt("mo",[new qt(Kt[e.replace(/^\\/,"")])]);return t.setAttribute("stretchy","true"),t},Qt={overrightarrow:[["rightarrow"],.888,522,"xMaxYMin"],overleftarrow:[["leftarrow"],.888,522,"xMinYMin"],underrightarrow:[["rightarrow"],.888,522,"xMaxYMin"],underleftarrow:[["leftarrow"],.888,522,"xMinYMin"],xrightarrow:[["rightarrow"],1.469,522,"xMaxYMin"],"\\cdrightarrow":[["rightarrow"],3,522,"xMaxYMin"],xleftarrow:[["leftarrow"],1.469,522,"xMinYMin"],"\\cdleftarrow":[["leftarrow"],3,522,"xMinYMin"],Overrightarrow:[["doublerightarrow"],.888,560,"xMaxYMin"],xRightarrow:[["doublerightarrow"],1.526,560,"xMaxYMin"],xLeftarrow:[["doubleleftarrow"],1.526,560,"xMinYMin"],overleftharpoon:[["leftharpoon"],.888,522,"xMinYMin"],xleftharpoonup:[["leftharpoon"],.888,522,"xMinYMin"],xleftharpoondown:[["leftharpoondown"],.888,522,"xMinYMin"],overrightharpoon:[["rightharpoon"],.888,522,"xMaxYMin"],xrightharpoonup:[["rightharpoon"],.888,522,"xMaxYMin"],xrightharpoondown:[["rightharpoondown"],.888,522,"xMaxYMin"],xlongequal:[["longequal"],.888,334,"xMinYMin"],"\\cdlongequal":[["longequal"],3,334,"xMinYMin"],xtwoheadleftarrow:[["twoheadleftarrow"],.888,334,"xMinYMin"],xtwoheadrightarrow:[["twoheadrightarrow"],.888,334,"xMaxYMin"],overleftrightarrow:[["leftarrow","rightarrow"],.888,522],overbrace:[["leftbrace","midbrace","rightbrace"],1.6,548],underbrace:[["leftbraceunder","midbraceunder","rightbraceunder"],1.6,548],underleftrightarrow:[["leftarrow","rightarrow"],.888,522],xleftrightarrow:[["leftarrow","rightarrow"],1.75,522],xLeftrightarrow:[["doubleleftarrow","doublerightarrow"],1.75,560],xrightleftharpoons:[["leftharpoondownplus","rightharpoonplus"],1.75,716],xleftrightharpoons:[["leftharpoonplus","rightharpoondownplus"],1.75,716],xhookleftarrow:[["leftarrow","righthook"],1.08,522],xhookrightarrow:[["lefthook","rightarrow"],1.08,522],overlinesegment:[["leftlinesegment","rightlinesegment"],.888,522],underlinesegment:[["leftlinesegment","rightlinesegment"],.888,522],overbracket:[["leftbracketover","rightbracketover"],1.6,440],underbracket:[["leftbracketunder","rightbracketunder"],1.6,410],overgroup:[["leftgroup","rightgroup"],.888,342],undergroup:[["leftgroupunder","rightgroupunder"],.888,342],xmapsto:[["leftmapsto","rightarrow"],1.5,522],xtofrom:[["leftToFrom","rightToFrom"],1.75,528],xrightleftarrows:[["baraboveleftarrow","rightarrowabovebar"],1.75,901],xrightequilibrium:[["baraboveshortleftharpoon","rightharpoonaboveshortbar"],1.75,716],xleftequilibrium:[["shortbaraboveleftharpoon","shortrightharpoonabovebar"],1.75,716]},er=new Set(["widehat","widecheck","widetilde","utilde"]),tr=function(e,t){const r=function(){let r=4e5;const n=e.label.slice(1);if(er.has(n)&&"base"in e){const o="ordgroup"===e.base.type?e.base.body.length:1;let s,i,a;if(o>5)"widehat"===n||"widecheck"===n?(s=420,r=2364,a=.42,i=n+"4"):(s=312,r=2340,a=.34,i="tilde4");else{const e=[1,1,2,2,3,3][o];"widehat"===n||"widecheck"===n?(r=[0,1062,2364,2364,2364][e],s=[0,239,300,360,420][e],a=[0,.24,.3,.3,.36,.42][e],i=n+e):(r=[0,600,1033,2339,2340][e],s=[0,260,286,306,312][e],a=[0,.26,.286,.3,.306,.34][e],i="tilde"+e)}const l=new $(i),c=new _([l],{width:"100%",height:N(a),viewBox:"0 0 "+r+" "+s,preserveAspectRatio:"none"});return{span:We([],[c],t),minWidth:0,height:a}}{const e=[],o=Qt[n];if(!o)throw new Error('No SVG data for "'+n+'".');const s=o[0],i=o[1],a=o[2],l=a/1e3,c=s.length;let h,m;if(1===c){if(4!==o.length)throw new Error('Expected 4-tuple for single-path SVG data "'+n+'".');h=["hide-tail"],m=[o[3]]}else if(2===c)h=["halfarrow-left","halfarrow-right"],m=["xMinYMin","xMaxYMin"];else{if(3!==c)throw new Error("Correct katexImagesData or update code here to support\n "+c+" children.");h=["brace-left","brace-center","brace-right"],m=["xMinYMin","xMidYMin","xMaxYMin"]}for(let n=0;n0&&(n.style.minWidth=N(o)),n},rr=new Set(["bin","close","inner","open","punct","rel"]),nr=new Set(["accent-token","mathord","op-token","spacing","textord"]);function or(e,t){if(!e||e.type!==t)throw new Error("Expected node of type "+t+", but got "+(e?"node of type "+e.type:String(e)));return e}function sr(e){const t=ir(e);if(!t)throw new Error("Expected node of symbol group type, but got "+(e?"node of type "+e.type:String(e)));return t}function ir(e){return"atom"===e.type||nr.has(e.type)?e:null}function ar(e,t,r){let o="";for(const s of e.body)if("textord"===s.type)o+=s.text;else{if(!r||"spacing"!==s.type||" "!==s.text)throw new n(t,e);o+=" "}return o}const lr=e=>{return e instanceof W?e:((t=e)instanceof j||t instanceof U||t instanceof I)&&1===e.children.length?lr(e.children[0]):void 0;var t},cr=(e,t)=>{let r,n,o;e&&"supsub"===e.type?(n=or(e.base,"accent"),r=n.base,e.base=r,o=function(e){if(e instanceof j)return e;throw new Error("Expected span but got "+String(e)+".")}(Mt(e,t)),e.base=n):(n=or(e,"accent"),r=n.base);const s=Mt(r,t.havingCrampedStyle());let i=0;var a,l;n.isShifty&&m(r)&&(i=null!=(a=null==(l=lr(s))?void 0:l.skew)?a:0);const c="\\c"===n.label;let h,u=c?s.height+s.depth:Math.min(s.height,t.fontMetrics().xHeight);if(n.isStretchy)h=tr(n,t),h=Ke({positionType:"firstBaseline",children:[{type:"elem",elem:s},{type:"elem",elem:h,wrapperClasses:["svg-align"],wrapperStyle:i>0?{width:"calc(100% - "+N(2*i)+")",marginLeft:N(2*i)}:void 0}]});else{let e,r;"\\vec"===n.label?(e=rt("vec",t),r=tt.vec[1]):(e=Ge({type:"textord",mode:n.mode,text:n.label},t),e=function(e){if(e instanceof W)return e;throw new Error("Expected symbolNode but got "+String(e)+".")}(e),e.italic=0,r=e.width,c&&(u+=e.depth)),h=Ye(["accent-body"],[e]);const o="\\textcircled"===n.label;o&&(h.classes.push("accent-full"),u=s.height);let a=i;o||(a-=r/2),h.style.left=N(a),"\\textcircled"===n.label&&(h.style.top=".2em"),h=Ke({positionType:"firstBaseline",children:[{type:"elem",elem:s},{type:"kern",size:-u},{type:"elem",elem:h}]})}const p=Ye(["mord","katex-accent"],[h],t);return o?(o.children[0]=p,o.height=Math.max(p.height,o.height),o.classes[0]="mord",o):p},hr=new RegExp(["\\acute","\\grave","\\ddot","\\tilde","\\bar","\\breve","\\check","\\hat","\\vec","\\dot","\\mathring"].map(e=>"\\"+e).join("|"));mt({type:"accent",names:["\\acute","\\grave","\\ddot","\\tilde","\\bar","\\breve","\\check","\\hat","\\vec","\\dot","\\mathring","\\widecheck","\\widehat","\\widetilde","\\overrightarrow","\\overleftarrow","\\Overrightarrow","\\overleftrightarrow","\\overgroup","\\overlinesegment","\\overleftharpoon","\\overrightharpoon"],numArgs:1,handler:(e,t)=>{const r=pt(t[0]),n=!hr.test(e.funcName),o=!n||"\\widehat"===e.funcName||"\\widetilde"===e.funcName||"\\widecheck"===e.funcName;return{type:"accent",mode:e.parser.mode,label:e.funcName,isStretchy:n,isShifty:o,base:r}},htmlBuilder:cr,mathmlBuilder:(e,t)=>{const r=e.isStretchy?Jt(e.label):new Bt("mo",[Et(e.label,e.mode)]),n=new Bt("mover",[Vt(e.base,t),r]);return n.setAttribute("accent","true"),n}}),mt({type:"accent",names:["\\'","\\`","\\^","\\~","\\=","\\u","\\.",'\\"',"\\c","\\r","\\H","\\v","\\textcircled"],numArgs:1,allowedInText:!0,allowedInMath:!0,argTypes:["primitive"],handler:(e,t)=>{const r=t[0];let n=e.parser.mode;return"math"===n&&(e.parser.settings.reportNonstrict("mathVsTextAccents","LaTeX's accent "+e.funcName+" works only in text mode"),n="text"),{type:"accent",mode:n,label:e.funcName,isStretchy:!1,isShifty:!0,base:r}}}),mt({type:"accentUnder",names:["\\underleftarrow","\\underrightarrow","\\underleftrightarrow","\\undergroup","\\underlinesegment","\\utilde"],numArgs:1,handler:(e,t)=>{let r=e.parser,n=e.funcName;const o=t[0];return{type:"accentUnder",mode:r.mode,label:n,base:o}},htmlBuilder:(e,t)=>{const r=Mt(e.base,t),n=tr(e,t),o="\\utilde"===e.label?.12:0,s=Ke({positionType:"top",positionData:r.height,children:[{type:"elem",elem:n,wrapperClasses:["svg-align"]},{type:"kern",size:o},{type:"elem",elem:r}]});return Ye(["mord","accentunder"],[s],t)},mathmlBuilder:(e,t)=>{const r=Jt(e.label),n=new Bt("munder",[Vt(e.base,t),r]);return n.setAttribute("accentunder","true"),n}});const mr=e=>{const t=new Bt("mpadded",e?[e]:[]);return t.setAttribute("width","+0.6em"),t.setAttribute("lspace","0.3em"),t};mt({type:"xArrow",names:["\\xleftarrow","\\xrightarrow","\\xLeftarrow","\\xRightarrow","\\xleftrightarrow","\\xLeftrightarrow","\\xhookleftarrow","\\xhookrightarrow","\\xmapsto","\\xrightharpoondown","\\xrightharpoonup","\\xleftharpoondown","\\xleftharpoonup","\\xrightleftharpoons","\\xleftrightharpoons","\\xlongequal","\\xtwoheadrightarrow","\\xtwoheadleftarrow","\\xtofrom","\\xrightleftarrows","\\xrightequilibrium","\\xleftequilibrium","\\\\cdrightarrow","\\\\cdleftarrow","\\\\cdlongequal"],numArgs:1,numOptionalArgs:1,handler(e,t,r){let n=e.parser,o=e.funcName;return{type:"xArrow",mode:n.mode,label:o,body:t[0],below:r[0]}},htmlBuilder(e,t){const r=t.style;let n=t.havingStyle(r.sup());const o=Ze(Mt(e.body,n,t),t),s="\\x"===e.label.slice(0,2)?"x":"cd";let i;o.classes.push(s+"-arrow-pad"),e.below&&(n=t.havingStyle(r.sub()),i=Ze(Mt(e.below,n,t),t),i.classes.push(s+"-arrow-pad"));const a=tr(e,t),l=-t.fontMetrics().axisHeight+.5*a.height;let c,h=-t.fontMetrics().axisHeight-.5*a.height-.111;if((o.depth>.25||"\\xleftequilibrium"===e.label)&&(h-=o.depth),i){const e=-t.fontMetrics().axisHeight+i.height+.5*a.height+.111;c=Ke({positionType:"individualShift",children:[{type:"elem",elem:o,shift:h},{type:"elem",elem:a,shift:l,wrapperClasses:["svg-align"]},{type:"elem",elem:i,shift:e}]})}else c=Ke({positionType:"individualShift",children:[{type:"elem",elem:o,shift:h},{type:"elem",elem:a,shift:l,wrapperClasses:["svg-align"]}]});return Ye(["mrel","x-arrow"],[c],t)},mathmlBuilder(e,t){const r=Jt(e.label);let n;if(r.setAttribute("minsize","x"===e.label.charAt(0)?"1.75em":"3.0em"),e.body){const o=mr(Vt(e.body,t));if(e.below){const s=mr(Vt(e.below,t));n=new Bt("munderover",[r,s,o])}else n=new Bt("mover",[r,o])}else if(e.below){const o=mr(Vt(e.below,t));n=new Bt("munder",[r,o])}else n=mr(),n=new Bt("mover",[r,n]);return n}}),mt({type:"mclass",names:["\\mathord","\\mathbin","\\mathrel","\\mathopen","\\mathclose","\\mathpunct","\\mathinner"],numArgs:1,primitive:!0,handler(e,t){let r=e.parser,n=e.funcName;const o=t[0];return{type:"mclass",mode:r.mode,mclass:"m"+n.slice(5),body:dt(o),isCharacterBox:m(o)}},htmlBuilder:function(e,t){const r=xt(e.body,t,!0);return Ye([e.mclass],r,t)},mathmlBuilder:function(e,t){let r;const n=Ft(e.body,t);return"minner"===e.mclass?r=new Bt("mpadded",n):"mord"===e.mclass?e.isCharacterBox?(r=n[0],r.type="mi"):r=new Bt("mi",n):(e.isCharacterBox?(r=n[0],r.type="mo"):r=new Bt("mo",n),"mbin"===e.mclass?(r.attributes.lspace="0.22em",r.attributes.rspace="0.22em"):"mpunct"===e.mclass?(r.attributes.lspace="0em",r.attributes.rspace="0.17em"):"mopen"!==e.mclass&&"mclose"!==e.mclass||(r.attributes.lspace="0em",r.attributes.rspace="0em")),r}});const ur=e=>{const t="ordgroup"===e.type&&e.body.length?e.body[0]:e;return"atom"!==t.type||"bin"!==t.family&&"rel"!==t.family?"mord":"m"+t.family};mt({type:"mclass",names:["\\@binrel"],numArgs:2,handler(e,t){return{type:"mclass",mode:e.parser.mode,mclass:ur(t[0]),body:dt(t[1]),isCharacterBox:m(t[1])}}}),mt({type:"mclass",names:["\\stackrel","\\overset","\\underset"],numArgs:2,handler(e,t){let r=e.parser,n=e.funcName;const o=t[1],s=t[0];let i;i="\\stackrel"!==n?ur(o):"mrel";const a={type:"op",mode:o.mode,limits:!0,alwaysHandleSupSub:!0,parentIsSupSub:!1,symbol:!1,suppressBaseShift:"\\stackrel"!==n,body:dt(o)},l="\\underset"===n?{type:"supsub",mode:s.mode,base:a,sub:s}:{type:"supsub",mode:s.mode,base:a,sup:s};return{type:"mclass",mode:r.mode,mclass:i,body:[l],isCharacterBox:m(l)}}}),mt({type:"pmb",names:["\\pmb"],numArgs:1,allowedInText:!0,handler(e,t){return{type:"pmb",mode:e.parser.mode,mclass:ur(t[0]),body:dt(t[0])}},htmlBuilder(e,t){const r=xt(e.body,t,!0),n=Ye([e.mclass],r,t);return n.style.textShadow="0.02em 0.01em 0.04px",n},mathmlBuilder(e,t){const r=Ft(e.body,t),n=new Bt("mstyle",r);return n.setAttribute("style","text-shadow: 0.02em 0.01em 0.04px"),n}});const pr={">":"\\\\cdrightarrow","<":"\\\\cdleftarrow","=":"\\\\cdlongequal",A:"\\uparrow",V:"\\downarrow","|":"\\Vert",".":"no arrow"},dr=()=>({type:"styling",body:[],mode:"math",style:"display",resetFont:!0}),gr=e=>"textord"===e.type&&"@"===e.text,fr=(e,t)=>("mathord"===e.type||"atom"===e.type)&&e.text===t;function br(e,t,r){const n=pr[e];switch(n){case"\\\\cdrightarrow":case"\\\\cdleftarrow":return r.callFunction(n,[t[0]],[t[1]]);case"\\uparrow":case"\\downarrow":{const e={type:"atom",text:n,mode:"math",family:"rel"},o={type:"ordgroup",mode:"math",body:[r.callFunction("\\\\cdleft",[t[0]],[]),r.callFunction("\\Big",[e],[]),r.callFunction("\\\\cdright",[t[1]],[])]};return r.callFunction("\\\\cdparent",[o],[])}case"\\\\cdlongequal":return r.callFunction("\\\\cdlongequal",[],[]);case"\\Vert":{const e={type:"textord",text:"\\Vert",mode:"math"};return r.callFunction("\\Big",[e],[])}default:return{type:"textord",text:" ",mode:"math"}}}mt({type:"cdlabel",names:["\\\\cdleft","\\\\cdright"],numArgs:1,handler(e,t){let r=e.parser,n=e.funcName;return{type:"cdlabel",mode:r.mode,side:n.slice(4),label:t[0]}},htmlBuilder(e,t){const r=t.havingStyle(t.style.sup()),n=Ze(Mt(e.label,r,t),t);return n.classes.push("cd-label-"+e.side),n.style.bottom=N(.8-n.depth),n.height=0,n.depth=0,n},mathmlBuilder(e,t){let r=new Bt("mrow",[Vt(e.label,t)]);return r=new Bt("mpadded",[r]),r.setAttribute("width","0"),"left"===e.side&&r.setAttribute("lspace","-1width"),r.setAttribute("voffset","0.7em"),r=new Bt("mstyle",[r]),r.setAttribute("displaystyle","false"),r.setAttribute("scriptlevel","1"),r}}),mt({type:"cdlabelparent",names:["\\\\cdparent"],numArgs:1,handler(e,t){return{type:"cdlabelparent",mode:e.parser.mode,fragment:t[0]}},htmlBuilder(e,t){const r=Ze(Mt(e.fragment,t),t);return r.classes.push("cd-vert-arrow"),r},mathmlBuilder(e,t){return new Bt("mrow",[Vt(e.fragment,t)])}}),mt({type:"textord",names:["\\@char"],numArgs:1,allowedInText:!0,handler(e,t){let r=e.parser;const o=ar(or(t[0],"ordgroup"),"\\@char has non-numeric argument");let s,i=parseInt(o);if(isNaN(i))throw new n("\\@char has non-numeric argument "+o);if(i<0||i>1114111)throw new n("\\@char with invalid code point "+o);return i<=65535?s=String.fromCharCode(i):(i-=65536,s=String.fromCharCode(55296+(i>>10),56320+(1023&i))),{type:"textord",mode:r.mode,text:s}}});mt({type:"color",names:["\\textcolor"],numArgs:2,allowedInText:!0,argTypes:["color","original"],handler(e,t){let r=e.parser;const n=or(t[0],"color-token").color,o=t[1];return{type:"color",mode:r.mode,color:n,body:dt(o)}},htmlBuilder:(e,t)=>{const r=xt(e.body,t.withColor(e.color),!1);return $e(r)},mathmlBuilder:(e,t)=>{const r=Ft(e.body,t.withColor(e.color)),n=new Bt("mstyle",r);return n.setAttribute("mathcolor",e.color),n}}),mt({type:"color",names:["\\color"],numArgs:1,allowedInText:!0,argTypes:["color"],handler(e,t){let r=e.parser,n=e.breakOnTokenText;const o=or(t[0],"color-token").color;r.gullet.macros.set("\\current@color",o);const s=r.parseExpression(!0,n);return{type:"color",mode:r.mode,color:o,body:s}}}),mt({type:"cr",names:["\\\\"],numArgs:0,numOptionalArgs:0,allowedInText:!0,handler(e,t,r){let n=e.parser;const o="["===n.gullet.future().text?n.parseSizeGroup(!0):null,s=!n.settings.displayMode||!n.settings.useStrictBehavior("newLineInDisplayMode","In LaTeX, \\\\ or \\newline does nothing in display mode");return{type:"cr",mode:n.mode,newLine:s,size:o&&or(o,"size").value}},htmlBuilder(e,t){const r=Ye(["mspace"],[],t);return e.newLine&&(r.classes.push("katex-newline"),e.size&&(r.style.marginTop=N(H(e.size,t)))),r},mathmlBuilder(e,t){const r=new Bt("mspace");return e.newLine&&(r.setAttribute("linebreak","newline"),e.size&&r.setAttribute("height",N(H(e.size,t)))),r}});const yr={"\\global":"\\global","\\long":"\\\\globallong","\\\\globallong":"\\\\globallong","\\def":"\\gdef","\\gdef":"\\gdef","\\edef":"\\xdef","\\xdef":"\\xdef","\\let":"\\\\globallet","\\futurelet":"\\\\globalfuture"},xr=e=>{const t=e.text;if(/^(?:[\\{}$&#^_]|EOF)$/.test(t))throw new n("Expected a control sequence",e);return t},wr=(e,t,r,n)=>{let o=e.gullet.macros.get(r.text);null==o&&(r.noexpand=!0,o={tokens:[r],numArgs:0,unexpandable:!e.gullet.isExpandable(r.text)}),e.gullet.macros.set(t,o,n)};mt({type:"internal",names:["\\global","\\long","\\\\globallong"],numArgs:0,allowedInText:!0,handler(e){let t=e.parser,r=e.funcName;t.consumeSpaces();const o=t.fetch();if(yr[o.text])return"\\global"!==r&&"\\\\globallong"!==r||(o.text=yr[o.text]),or(t.parseFunction(),"internal");throw new n("Invalid token after macro prefix",o)}}),mt({type:"internal",names:["\\def","\\gdef","\\edef","\\xdef"],numArgs:0,allowedInText:!0,primitive:!0,handler(e){let t=e.parser,r=e.funcName,o=t.gullet.popToken();const s=o.text;if(/^(?:[\\{}$&#^_]|EOF)$/.test(s))throw new n("Expected a control sequence",o);let i,a=0;const l=[[]];for(;"{"!==t.gullet.future().text;)if(o=t.gullet.popToken(),"#"===o.text){if("{"===t.gullet.future().text){i=t.gullet.future(),l[a].push("{");break}if(o=t.gullet.popToken(),!/^[1-9]$/.test(o.text))throw new n('Invalid argument number "'+o.text+'"');if(parseInt(o.text)!==a+1)throw new n('Argument number "'+o.text+'" out of order');a++,l.push([])}else{if("EOF"===o.text)throw new n("Expected a macro definition");l[a].push(o.text)}let c=t.gullet.consumeArg().tokens;return i&&c.unshift(i),"\\edef"!==r&&"\\xdef"!==r||(c=t.gullet.expandTokens(c),c.reverse()),t.gullet.macros.set(s,{tokens:c,numArgs:a,delimiters:l},r===yr[r]),{type:"internal",mode:t.mode}}}),mt({type:"internal",names:["\\let","\\\\globallet"],numArgs:0,allowedInText:!0,primitive:!0,handler(e){let t=e.parser,r=e.funcName;const n=xr(t.gullet.popToken());t.gullet.consumeSpaces();const o=(e=>{let t=e.gullet.popToken();return"="===t.text&&(t=e.gullet.popToken()," "===t.text&&(t=e.gullet.popToken())),t})(t);return wr(t,n,o,"\\\\globallet"===r),{type:"internal",mode:t.mode}}}),mt({type:"internal",names:["\\futurelet","\\\\globalfuture"],numArgs:0,allowedInText:!0,primitive:!0,handler(e){let t=e.parser,r=e.funcName;const n=xr(t.gullet.popToken()),o=t.gullet.popToken(),s=t.gullet.popToken();return wr(t,n,s,"\\\\globalfuture"===r),t.gullet.pushToken(s),t.gullet.pushToken(o),{type:"internal",mode:t.mode}}});const vr=function(e,t,r){const n=ee(ne.math[e]&&ne.math[e].replace||e,t,r);if(!n)throw new Error("Unsupported symbol "+e+" and font size "+t+".");return n},kr=function(e,t,r,n){const o=r.havingBaseStyle(t),s=Ye(n.concat(o.sizingClasses(r)),[e],r),i=o.sizeMultiplier/r.sizeMultiplier;return s.height*=i,s.depth*=i,s.maxFontSize=o.sizeMultiplier,s},zr=function(e,t,r){const n=t.havingBaseStyle(r),o=(1-t.sizeMultiplier/n.sizeMultiplier)*t.fontMetrics().axisHeight;e.classes.push("delimcenter"),e.style.top=N(o),e.height-=o,e.depth+=o},Sr=function(e,t,r,n,o,s){const i=function(e,t,r,n){return Pe(e,"Size"+t+"-Regular",r,n)}(e,t,o,n),a=kr(Ye(["delimsizing","size"+t],[i],n),S.TEXT,n,s);return r&&zr(a,n,S.TEXT),a},Mr=function(e,t,r){let n;n="Size1-Regular"===t?"delim-size1":"delim-size4";return{type:"elem",elem:Ye(["delimsizinginner",n],[Ye([],[Pe(e,t,r)])])}},Ar=function(e,t,r){const n=K["Size4-Regular"][e.charCodeAt(0)]?K["Size4-Regular"][e.charCodeAt(0)][4]:K["Size1-Regular"][e.charCodeAt(0)][4],o=new $("inner",function(e,t){switch(e){case"\u239c":return C("M291 0 H417 V"+t+" H291z");case"\u2223":return C("M145 0 H188 V"+t+" H145z");case"\u2225":return C("M145 0 H188 V"+t+" H145z")+C("M367 0 H410 V"+t+" H367z");case"\u239f":return C("M457 0 H583 V"+t+" H457z");case"\u23a2":return C("M319 0 H403 V"+t+" H319z");case"\u23a5":return C("M263 0 H347 V"+t+" H263z");case"\u23aa":return C("M384 0 H504 V"+t+" H384z");case"\u23d0":return C("M312 0 H355 V"+t+" H312z");case"\u2016":return C("M257 0 H300 V"+t+" H257z")+C("M478 0 H521 V"+t+" H478z");default:return""}}(e,Math.round(1e3*t))),s=new _([o],{width:N(n),height:N(t),style:"width:"+N(n),viewBox:"0 0 "+1e3*n+" "+Math.round(1e3*t),preserveAspectRatio:"xMinYMin"}),i=We([],[s],r);return i.height=t,i.style.height=N(t),i.style.width=N(n),{type:"elem",elem:i}},Tr={type:"kern",size:-.008},Cr=new Set(["|","\\lvert","\\rvert","\\vert"]),Br=new Set(["\\|","\\lVert","\\rVert","\\Vert"]),qr=function(e,t,r,n,o,s){let i,a,l,c,h="",m=0;i=l=c=e,a=null;let u="Size1-Regular";"\\uparrow"===e?l=c="\u23d0":"\\Uparrow"===e?l=c="\u2016":"\\downarrow"===e?i=l="\u23d0":"\\Downarrow"===e?i=l="\u2016":"\\updownarrow"===e?(i="\\uparrow",l="\u23d0",c="\\downarrow"):"\\Updownarrow"===e?(i="\\Uparrow",l="\u2016",c="\\Downarrow"):Cr.has(e)?(l="\u2223",h="vert",m=333):Br.has(e)?(l="\u2225",h="doublevert",m=556):"["===e||"\\lbrack"===e?(i="\u23a1",l="\u23a2",c="\u23a3",u="Size4-Regular",h="lbrack",m=667):"]"===e||"\\rbrack"===e?(i="\u23a4",l="\u23a5",c="\u23a6",u="Size4-Regular",h="rbrack",m=667):"\\lfloor"===e||"\u230a"===e?(l=i="\u23a2",c="\u23a3",u="Size4-Regular",h="lfloor",m=667):"\\lceil"===e||"\u2308"===e?(i="\u23a1",l=c="\u23a2",u="Size4-Regular",h="lceil",m=667):"\\rfloor"===e||"\u230b"===e?(l=i="\u23a5",c="\u23a6",u="Size4-Regular",h="rfloor",m=667):"\\rceil"===e||"\u2309"===e?(i="\u23a4",l=c="\u23a5",u="Size4-Regular",h="rceil",m=667):"("===e||"\\lparen"===e?(i="\u239b",l="\u239c",c="\u239d",u="Size4-Regular",h="lparen",m=875):")"===e||"\\rparen"===e?(i="\u239e",l="\u239f",c="\u23a0",u="Size4-Regular",h="rparen",m=875):"\\{"===e||"\\lbrace"===e?(i="\u23a7",a="\u23a8",c="\u23a9",l="\u23aa",u="Size4-Regular"):"\\}"===e||"\\rbrace"===e?(i="\u23ab",a="\u23ac",c="\u23ad",l="\u23aa",u="Size4-Regular"):"\\lgroup"===e||"\u27ee"===e?(i="\u23a7",c="\u23a9",l="\u23aa",u="Size4-Regular"):"\\rgroup"===e||"\u27ef"===e?(i="\u23ab",c="\u23ad",l="\u23aa",u="Size4-Regular"):"\\lmoustache"===e||"\u23b0"===e?(i="\u23a7",c="\u23ad",l="\u23aa",u="Size4-Regular"):"\\rmoustache"!==e&&"\u23b1"!==e||(i="\u23ab",c="\u23a9",l="\u23aa",u="Size4-Regular");const p=vr(i,u,o),d=p.height+p.depth,g=vr(l,u,o),f=g.height+g.depth,b=vr(c,u,o),y=b.height+b.depth;let x=0,w=1;if(null!==a){const e=vr(a,u,o);x=e.height+e.depth,w=2}const v=d+y+x,k=v+Math.max(0,Math.ceil((t-v)/(w*f)))*w*f;let z=n.fontMetrics().axisHeight;r&&(z*=n.sizeMultiplier);const M=k/2-z,A=[];if(h.length>0){const e=k-d-y,t=Math.round(1e3*k),r=function(e,t){switch(e){case"lbrack":return"M403 1759 V84 H666 V0 H319 V1759 v"+t+" v1759 v84 h347 v-84\nH403z M403 1759 V0 H319 V1759 v"+t+" v1759 v84 h84z";case"rbrack":return"M347 1759 V0 H0 V84 H263 V1759 v"+t+" v1759 H0 v84 H347z\nM347 1759 V0 H263 V1759 v"+t+" v1759 h84z";case"vert":return"M145 15 v585 v"+t+" v585 c2.667,10,9.667,15,21,15\nc10,0,16.667,-5,20,-15 v-585 v"+-t+" v-585 c-2.667,-10,-9.667,-15,-21,-15\nc-10,0,-16.667,5,-20,15z M188 15 H145 v585 v"+t+" v585 h43z";case"doublevert":return"M145 15 v585 v"+t+" v585 c2.667,10,9.667,15,21,15\nc10,0,16.667,-5,20,-15 v-585 v"+-t+" v-585 c-2.667,-10,-9.667,-15,-21,-15\nc-10,0,-16.667,5,-20,15z M188 15 H145 v585 v"+t+" v585 h43z\nM367 15 v585 v"+t+" v585 c2.667,10,9.667,15,21,15\nc10,0,16.667,-5,20,-15 v-585 v"+-t+" v-585 c-2.667,-10,-9.667,-15,-21,-15\nc-10,0,-16.667,5,-20,15z M410 15 H367 v585 v"+t+" v585 h43z";case"lfloor":return"M319 602 V0 H403 V602 v"+t+" v1715 h263 v84 H319z\nMM319 602 V0 H403 V602 v"+t+" v1715 H319z";case"rfloor":return"M319 602 V0 H403 V602 v"+t+" v1799 H0 v-84 H319z\nMM319 602 V0 H403 V602 v"+t+" v1715 H319z";case"lceil":return"M403 1759 V84 H666 V0 H319 V1759 v"+t+" v602 h84z\nM403 1759 V0 H319 V1759 v"+t+" v602 h84z";case"rceil":return"M347 1759 V0 H0 V84 H263 V1759 v"+t+" v602 h84z\nM347 1759 V0 h-84 V1759 v"+t+" v602 h84z";case"lparen":return"M863,9c0,-2,-2,-5,-6,-9c0,0,-17,0,-17,0c-12.7,0,-19.3,0.3,-20,1\nc-5.3,5.3,-10.3,11,-15,17c-242.7,294.7,-395.3,682,-458,1162c-21.3,163.3,-33.3,349,\n-36,557 l0,"+(t+84)+"c0.2,6,0,26,0,60c2,159.3,10,310.7,24,454c53.3,528,210,\n949.7,470,1265c4.7,6,9.7,11.7,15,17c0.7,0.7,7,1,19,1c0,0,18,0,18,0c4,-4,6,-7,6,-9\nc0,-2.7,-3.3,-8.7,-10,-18c-135.3,-192.7,-235.5,-414.3,-300.5,-665c-65,-250.7,-102.5,\n-544.7,-112.5,-882c-2,-104,-3,-167,-3,-189\nl0,-"+(t+92)+"c0,-162.7,5.7,-314,17,-454c20.7,-272,63.7,-513,129,-723c65.3,\n-210,155.3,-396.3,270,-559c6.7,-9.3,10,-15.3,10,-18z";case"rparen":return"M76,0c-16.7,0,-25,3,-25,9c0,2,2,6.3,6,13c21.3,28.7,42.3,60.3,\n63,95c96.7,156.7,172.8,332.5,228.5,527.5c55.7,195,92.8,416.5,111.5,664.5\nc11.3,139.3,17,290.7,17,454c0,28,1.7,43,3.3,45l0,"+(t+9)+"\nc-3,4,-3.3,16.7,-3.3,38c0,162,-5.7,313.7,-17,455c-18.7,248,-55.8,469.3,-111.5,664\nc-55.7,194.7,-131.8,370.3,-228.5,527c-20.7,34.7,-41.7,66.3,-63,95c-2,3.3,-4,7,-6,11\nc0,7.3,5.7,11,17,11c0,0,11,0,11,0c9.3,0,14.3,-0.3,15,-1c5.3,-5.3,10.3,-11,15,-17\nc242.7,-294.7,395.3,-681.7,458,-1161c21.3,-164.7,33.3,-350.7,36,-558\nl0,-"+(t+144)+"c-2,-159.3,-10,-310.7,-24,-454c-53.3,-528,-210,-949.7,\n-470,-1265c-4.7,-6,-9.7,-11.7,-15,-17c-0.7,-0.7,-6.7,-1,-18,-1z";default:throw new Error("Unknown stretchy delimiter.")}}(h,Math.round(1e3*e)),o=new $(h,r),s=N(m/1e3),i=N(t/1e3),a=new _([o],{width:s,height:i,viewBox:"0 0 "+m+" "+t}),l=We([],[a],n);l.height=t/1e3,l.style.width=s,l.style.height=i,A.push({type:"elem",elem:l})}else{if(A.push(Mr(c,u,o)),A.push(Tr),null===a){const e=k-d-y+.016;A.push(Ar(l,e,n))}else{const e=(k-d-y-x)/2+.016;A.push(Ar(l,e,n)),A.push(Tr),A.push(Mr(a,u,o)),A.push(Tr),A.push(Ar(l,e,n))}A.push(Tr),A.push(Mr(i,u,o))}const T=n.havingBaseStyle(S.TEXT),C=Ke({positionType:"bottom",positionData:M,children:A});return kr(Ye(["delimsizing","mult"],[C],T),S.TEXT,n,s)},Ir=.08,Rr=function(e,t,r,n,o){const s=function(e,t,r){t*=1e3;let n="";switch(e){case"sqrtMain":n=function(e,t){return"M95,"+(622+e+t)+"\nc-2.7,0,-7.17,-2.7,-13.5,-8c-5.8,-5.3,-9.5,-10,-9.5,-14\nc0,-2,0.3,-3.3,1,-4c1.3,-2.7,23.83,-20.7,67.5,-54\nc44.2,-33.3,65.8,-50.3,66.5,-51c1.3,-1.3,3,-2,5,-2c4.7,0,8.7,3.3,12,10\ns173,378,173,378c0.7,0,35.3,-71,104,-213c68.7,-142,137.5,-285,206.5,-429\nc69,-144,104.5,-217.7,106.5,-221\nl"+e/2.075+" -"+e+"\nc5.3,-9.3,12,-14,20,-14\nH400000v"+(40+e)+"H845.2724\ns-225.272,467,-225.272,467s-235,486,-235,486c-2.7,4.7,-9,7,-19,7\nc-6,0,-10,-1,-12,-3s-194,-422,-194,-422s-65,47,-65,47z\nM"+(834+e)+" "+t+"h400000v"+(40+e)+"h-400000z"}(t,B);break;case"sqrtSize1":n=function(e,t){return"M263,"+(601+e+t)+"c0.7,0,18,39.7,52,119\nc34,79.3,68.167,158.7,102.5,238c34.3,79.3,51.8,119.3,52.5,120\nc340,-704.7,510.7,-1060.3,512,-1067\nl"+e/2.084+" -"+e+"\nc4.7,-7.3,11,-11,19,-11\nH40000v"+(40+e)+"H1012.3\ns-271.3,567,-271.3,567c-38.7,80.7,-84,175,-136,283c-52,108,-89.167,185.3,-111.5,232\nc-22.3,46.7,-33.8,70.3,-34.5,71c-4.7,4.7,-12.3,7,-23,7s-12,-1,-12,-1\ns-109,-253,-109,-253c-72.7,-168,-109.3,-252,-110,-252c-10.7,8,-22,16.7,-34,26\nc-22,17.3,-33.3,26,-34,26s-26,-26,-26,-26s76,-59,76,-59s76,-60,76,-60z\nM"+(1001+e)+" "+t+"h400000v"+(40+e)+"h-400000z"}(t,B);break;case"sqrtSize2":n=function(e,t){return"M983 "+(10+e+t)+"\nl"+e/3.13+" -"+e+"\nc4,-6.7,10,-10,18,-10 H400000v"+(40+e)+"\nH1013.1s-83.4,268,-264.1,840c-180.7,572,-277,876.3,-289,913c-4.7,4.7,-12.7,7,-24,7\ns-12,0,-12,0c-1.3,-3.3,-3.7,-11.7,-7,-25c-35.3,-125.3,-106.7,-373.3,-214,-744\nc-10,12,-21,25,-33,39s-32,39,-32,39c-6,-5.3,-15,-14,-27,-26s25,-30,25,-30\nc26.7,-32.7,52,-63,76,-91s52,-60,52,-60s208,722,208,722\nc56,-175.3,126.3,-397.3,211,-666c84.7,-268.7,153.8,-488.2,207.5,-658.5\nc53.7,-170.3,84.5,-266.8,92.5,-289.5z\nM"+(1001+e)+" "+t+"h400000v"+(40+e)+"h-400000z"}(t,B);break;case"sqrtSize3":n=function(e,t){return"M424,"+(2398+e+t)+"\nc-1.3,-0.7,-38.5,-172,-111.5,-514c-73,-342,-109.8,-513.3,-110.5,-514\nc0,-2,-10.7,14.3,-32,49c-4.7,7.3,-9.8,15.7,-15.5,25c-5.7,9.3,-9.8,16,-12.5,20\ns-5,7,-5,7c-4,-3.3,-8.3,-7.7,-13,-13s-13,-13,-13,-13s76,-122,76,-122s77,-121,77,-121\ns209,968,209,968c0,-2,84.7,-361.7,254,-1079c169.3,-717.3,254.7,-1077.7,256,-1081\nl"+e/4.223+" -"+e+"c4,-6.7,10,-10,18,-10 H400000\nv"+(40+e)+"H1014.6\ns-87.3,378.7,-272.6,1166c-185.3,787.3,-279.3,1182.3,-282,1185\nc-2,6,-10,9,-24,9\nc-8,0,-12,-0.7,-12,-2z M"+(1001+e)+" "+t+"\nh400000v"+(40+e)+"h-400000z"}(t,B);break;case"sqrtSize4":n=function(e,t){return"M473,"+(2713+e+t)+"\nc339.3,-1799.3,509.3,-2700,510,-2702 l"+e/5.298+" -"+e+"\nc3.3,-7.3,9.3,-11,18,-11 H400000v"+(40+e)+"H1017.7\ns-90.5,478,-276.2,1466c-185.7,988,-279.5,1483,-281.5,1485c-2,6,-10,9,-24,9\nc-8,0,-12,-0.7,-12,-2c0,-1.3,-5.3,-32,-16,-92c-50.7,-293.3,-119.7,-693.3,-207,-1200\nc0,-1.3,-5.3,8.7,-16,30c-10.7,21.3,-21.3,42.7,-32,64s-16,33,-16,33s-26,-26,-26,-26\ns76,-153,76,-153s77,-151,77,-151c0.7,0.7,35.7,202,105,604c67.3,400.7,102,602.7,104,\n606zM"+(1001+e)+" "+t+"h400000v"+(40+e)+"H1017.7z"}(t,B);break;case"sqrtTall":n=function(e,t,r){return"M702 "+(e+t)+"H400000"+(40+e)+"\nH742v"+(r-54-t-e)+"l-4 4-4 4c-.667.7 -2 1.5-4 2.5s-4.167 1.833-6.5 2.5-5.5 1-9.5 1\nh-12l-28-84c-16.667-52-96.667 -294.333-240-727l-212 -643 -85 170\nc-4-3.333-8.333-7.667-13 -13l-13-13l77-155 77-156c66 199.333 139 419.667\n219 661 l218 661zM702 "+t+"H400000v"+(40+e)+"H742z"}(t,B,r)}return n}(e,n,r),i=new $(e,s),a=new _([i],{width:"400em",height:N(t),viewBox:"0 0 400000 "+r,preserveAspectRatio:"xMinYMin slice"});return We(["hide-tail"],[a],o)},Or=new Set(["(","\\lparen",")","\\rparen","[","\\lbrack","]","\\rbrack","\\{","\\lbrace","\\}","\\rbrace","\\lfloor","\\rfloor","\u230a","\u230b","\\lceil","\\rceil","\u2308","\u2309","\\surd"]),Er=new Set(["\\uparrow","\\downarrow","\\updownarrow","\\Uparrow","\\Downarrow","\\Updownarrow","|","\\|","\\vert","\\Vert","\\lvert","\\rvert","\\lVert","\\rVert","\\lgroup","\\rgroup","\u27ee","\u27ef","\\lmoustache","\\rmoustache","\u23b0","\u23b1"]),Hr=new Set(["<",">","\\langle","\\rangle","/","\\backslash","\\lt","\\gt"]),Nr=[0,1.2,1.8,2.4,3],Dr=function(e,t,r,o,s){if("<"===e||"\\lt"===e||"\u27e8"===e?e="\\langle":">"!==e&&"\\gt"!==e&&"\u27e9"!==e||(e="\\rangle"),Or.has(e)||Hr.has(e))return Sr(e,t,!1,r,o,s);if(Er.has(e))return qr(e,Nr[t],!1,r,o,s);throw new n("Illegal delimiter: '"+e+"'")},Lr=[{type:"small",style:S.SCRIPTSCRIPT},{type:"small",style:S.SCRIPT},{type:"small",style:S.TEXT},{type:"large",size:1},{type:"large",size:2},{type:"large",size:3},{type:"large",size:4}],Fr=[{type:"small",style:S.SCRIPTSCRIPT},{type:"small",style:S.SCRIPT},{type:"small",style:S.TEXT},{type:"stack"}],Pr=[{type:"small",style:S.SCRIPTSCRIPT},{type:"small",style:S.SCRIPT},{type:"small",style:S.TEXT},{type:"large",size:1},{type:"large",size:2},{type:"large",size:3},{type:"large",size:4},{type:"stack"}],Vr=function(e){if("small"===e.type)return"Main-Regular";if("large"===e.type)return"Size"+e.size+"-Regular";if("stack"===e.type)return"Size4-Regular";{const t=e.type;throw new Error("Add support for delim type '"+t+"' here.")}},Gr=function(e,t,r,n){for(let o=Math.min(2,3-n.style.size);ot)return s}return r[r.length-1]},jr=function(e,t,r,n,o,s){let i;"<"===e||"\\lt"===e||"\u27e8"===e?e="\\langle":">"!==e&&"\\gt"!==e&&"\u27e9"!==e||(e="\\rangle"),i=Hr.has(e)?Lr:Or.has(e)?Pr:Fr;const a=Gr(e,t,i,n);return"small"===a.type?function(e,t,r,n,o,s){const i=Pe(e,"Main-Regular",o,n),a=kr(i,t,n,s);return r&&zr(a,n,t),a}(e,a.style,r,n,o,s):"large"===a.type?Sr(e,a.size,r,n,o,s):qr(e,t,r,n,o,s)},Ur=function(e,t,r,n,o,s){const i=n.fontMetrics().axisHeight*n.sizeMultiplier,a=5/n.fontMetrics().ptPerEm,l=Math.max(t-i,r+i),c=Math.max(l/500*901,2*l-a);return jr(e,c,!0,n,o,s)},Xr={"\\bigl":{mclass:"mopen",size:1},"\\Bigl":{mclass:"mopen",size:2},"\\biggl":{mclass:"mopen",size:3},"\\Biggl":{mclass:"mopen",size:4},"\\bigr":{mclass:"mclose",size:1},"\\Bigr":{mclass:"mclose",size:2},"\\biggr":{mclass:"mclose",size:3},"\\Biggr":{mclass:"mclose",size:4},"\\bigm":{mclass:"mrel",size:1},"\\Bigm":{mclass:"mrel",size:2},"\\biggm":{mclass:"mrel",size:3},"\\Biggm":{mclass:"mrel",size:4},"\\big":{mclass:"mord",size:1},"\\Big":{mclass:"mord",size:2},"\\bigg":{mclass:"mord",size:3},"\\Bigg":{mclass:"mord",size:4}},Yr=new Set(["(","\\lparen",")","\\rparen","[","\\lbrack","]","\\rbrack","\\{","\\lbrace","\\}","\\rbrace","\\lfloor","\\rfloor","\u230a","\u230b","\\lceil","\\rceil","\u2308","\u2309","<",">","\\langle","\u27e8","\\rangle","\u27e9","\\lt","\\gt","\\lvert","\\rvert","\\lVert","\\rVert","\\lgroup","\\rgroup","\u27ee","\u27ef","\\lmoustache","\\rmoustache","\u23b0","\u23b1","/","\\backslash","|","\\vert","\\|","\\Vert","\\uparrow","\\Uparrow","\\downarrow","\\Downarrow","\\updownarrow","\\Updownarrow","."]);function Wr(e){return"isMiddle"in e}function _r(e,t){const r=ir(e);if(r&&Yr.has(r.text))return r;throw new n(r?"Invalid delimiter '"+r.text+"' after '"+t.funcName+"'":"Invalid delimiter type '"+e.type+"'",e)}function $r(e){if(!e.body)throw new Error("Bug: The leftright ParseNode wasn't fully parsed.")}mt({type:"delimsizing",names:["\\bigl","\\Bigl","\\biggl","\\Biggl","\\bigr","\\Bigr","\\biggr","\\Biggr","\\bigm","\\Bigm","\\biggm","\\Biggm","\\big","\\Big","\\bigg","\\Bigg"],numArgs:1,argTypes:["primitive"],handler:(e,t)=>{const r=_r(pt(t[0]),e);return{type:"delimsizing",mode:e.parser.mode,size:Xr[e.funcName].size,mclass:Xr[e.funcName].mclass,delim:r.text}},htmlBuilder:(e,t)=>"."===e.delim?Ye([e.mclass]):Dr(e.delim,e.size,t,e.mode,[e.mclass]),mathmlBuilder:e=>{const t=[];"."!==e.delim&&t.push(Et(e.delim,e.mode));const r=new Bt("mo",t);"mopen"===e.mclass||"mclose"===e.mclass?r.setAttribute("fence","true"):r.setAttribute("fence","false"),r.setAttribute("stretchy","true");const n=N(Nr[e.size]);return r.setAttribute("minsize",n),r.setAttribute("maxsize",n),r}}),mt({type:"leftright-right",names:["\\right"],numArgs:1,primitive:!0,handler:(e,t)=>{const r=e.parser.gullet.macros.get("\\current@color");if(r&&"string"!=typeof r)throw new n("\\current@color set to non-string in \\right");return{type:"leftright-right",mode:e.parser.mode,delim:_r(t[0],e).text,color:r}}}),mt({type:"leftright",names:["\\left"],numArgs:1,primitive:!0,handler:(e,t)=>{const r=_r(t[0],e),n=e.parser;++n.leftrightDepth;const o=n.parseExpression(!1);--n.leftrightDepth,n.expect("\\right",!1);const s=or(n.parseFunction(),"leftright-right");return{type:"leftright",mode:n.mode,body:o,left:r.text,right:s.delim,rightColor:s.color}},htmlBuilder:(e,t)=>{$r(e);const r=xt(e.body,t,!0,["mopen","mclose"]);let n,o,s=0,i=0,a=!1;for(let e=0;e{$r(e);const r=Ft(e.body,t);if("."!==e.left){const t=new Bt("mo",[Et(e.left,e.mode)]);t.setAttribute("fence","true"),r.unshift(t)}if("."!==e.right){const t=new Bt("mo",[Et(e.right,e.mode)]);t.setAttribute("fence","true"),e.rightColor&&t.setAttribute("mathcolor",e.rightColor),r.push(t)}return Ht(r)}}),mt({type:"middle",names:["\\middle"],numArgs:1,primitive:!0,handler:(e,t)=>{const r=_r(t[0],e);if(!e.parser.leftrightDepth)throw new n("\\middle without preceding \\left",r);return{type:"middle",mode:e.parser.mode,delim:r.text}},htmlBuilder:(e,t)=>{let r;return"."===e.delim?r=St(t,[]):(r=Dr(e.delim,1,t,e.mode,[]),r.isMiddle={delim:e.delim,options:t}),r},mathmlBuilder:(e,t)=>{const r="\\vert"===e.delim||"|"===e.delim?Et("|","text"):Et(e.delim,e.mode),n=new Bt("mo",[r]);return n.setAttribute("fence","true"),n.setAttribute("lspace","0.05em"),n.setAttribute("rspace","0.05em"),n}});mt({type:"enclose",names:["\\colorbox"],numArgs:2,allowedInText:!0,argTypes:["color","hbox"],handler(e,t,r){let n=e.parser,o=e.funcName;const s=or(t[0],"color-token").color,i=t[1];return{type:"enclose",mode:n.mode,label:o,backgroundColor:s,body:i}},htmlBuilder:(e,t)=>{const r=Ze(Mt(e.body,t),t),n=e.label.slice(1);let o,s,i=t.sizeMultiplier;const a=m(e.body);if("sout"===n)o=Ye(["katex-stretchy","katex-sout"]),o.height=t.fontMetrics().defaultRuleThickness/i,s=-.5*t.fontMetrics().xHeight;else if("phase"===n){const e=H({number:.6,unit:"pt"},t),n=H({number:.35,unit:"ex"},t);i/=t.havingBaseSizing().sizeMultiplier;const a=r.height+r.depth+e+n;r.style.paddingLeft=N(a/2+e);const c=Math.floor(1e3*a*i),h="M400000 "+(l=c)+" H0 L"+l/2+" 0 l65 45 L145 "+(l-80)+" H400000z",m=new _([new $("phase",h)],{width:"400em",height:N(c/1e3),viewBox:"0 0 400000 "+c,preserveAspectRatio:"xMinYMin slice"});o=We(["hide-tail"],[m],t),o.style.height=N(a),s=r.depth+e+n}else{let i,l;/cancel/.test(n)?a||r.classes.push("cancel-pad"):"angl"===n?r.classes.push("anglpad"):r.classes.push("boxpad");let c=0;/box/.test(n)?(c=Math.max(t.fontMetrics().fboxrule,t.minRuleThickness),i=t.fontMetrics().fboxsep+("colorbox"===n?0:c),l=i):"angl"===n?(c=Math.max(t.fontMetrics().defaultRuleThickness,t.minRuleThickness),i=4*c,l=Math.max(0,.25-r.depth)):(i=a?.2:0,l=i),o=function(e,t,r,n,o){let s;const i=e.height+e.depth+r+n;if(/fbox|color|angl/.test(t)){if(s=Ye(["katex-stretchy",t],[],o),"fbox"===t){const e=o.color&&o.getColor();e&&(s.style.borderColor=e)}}else{const e=[];/^[bx]cancel$/.test(t)&&e.push(new Z({x1:"0",y1:"0",x2:"100%",y2:"100%","stroke-width":"0.046em"})),/^x?cancel$/.test(t)&&e.push(new Z({x1:"0",y1:"100%",x2:"100%",y2:"0","stroke-width":"0.046em"}));const r=new _(e,{width:"100%",height:N(i)});s=We([],[r],o)}return s.height=i,s.style.height=N(i),s}(r,n,i,l,t),/fbox|boxed|fcolorbox/.test(n)?(o.style.borderStyle="solid",o.style.borderWidth=N(c)):"angl"===n&&.049!==c&&(o.style.borderTopWidth=N(c),o.style.borderRightWidth=N(c)),s=r.depth+l,e.backgroundColor&&(o.style.backgroundColor=e.backgroundColor,e.borderColor&&(o.style.borderColor=e.borderColor))}var l;let c;if(e.backgroundColor)c=Ke({positionType:"individualShift",children:[{type:"elem",elem:o,shift:s},{type:"elem",elem:r,shift:0}]});else{const e=/cancel|phase/.test(n)?["svg-align"]:[];c=Ke({positionType:"individualShift",children:[{type:"elem",elem:r,shift:0},{type:"elem",elem:o,shift:s,wrapperClasses:e}]})}return/cancel/.test(n)&&(c.height=r.height,c.depth=r.depth),/cancel/.test(n)&&!a?Ye(["mord","cancel-lap"],[c],t):Ye(["mord"],[c],t)},mathmlBuilder:(e,t)=>{let r;const n=new Bt(e.label.includes("colorbox")?"mpadded":"menclose",[Vt(e.body,t)]);switch(e.label){case"\\cancel":n.setAttribute("notation","updiagonalstrike");break;case"\\bcancel":n.setAttribute("notation","downdiagonalstrike");break;case"\\phase":n.setAttribute("notation","phasorangle");break;case"\\sout":n.setAttribute("notation","horizontalstrike");break;case"\\fbox":n.setAttribute("notation","box");break;case"\\angl":n.setAttribute("notation","actuarial");break;case"\\fcolorbox":case"\\colorbox":if(r=t.fontMetrics().fboxsep*t.fontMetrics().ptPerEm,n.setAttribute("width","+"+2*r+"pt"),n.setAttribute("height","+"+2*r+"pt"),n.setAttribute("lspace",r+"pt"),n.setAttribute("voffset",r+"pt"),"\\fcolorbox"===e.label){const r=Math.max(t.fontMetrics().fboxrule,t.minRuleThickness);n.setAttribute("style","border: "+N(r)+" solid "+e.borderColor)}break;case"\\xcancel":n.setAttribute("notation","updiagonalstrike downdiagonalstrike")}return e.backgroundColor&&n.setAttribute("mathbackground",e.backgroundColor),n}}),mt({type:"enclose",names:["\\fcolorbox"],numArgs:3,allowedInText:!0,argTypes:["color","color","hbox"],handler(e,t,r){let n=e.parser,o=e.funcName;const s=or(t[0],"color-token").color,i=or(t[1],"color-token").color,a=t[2];return{type:"enclose",mode:n.mode,label:o,backgroundColor:i,borderColor:s,body:a}}}),mt({type:"enclose",names:["\\fbox"],numArgs:1,argTypes:["hbox"],allowedInText:!0,handler(e,t){return{type:"enclose",mode:e.parser.mode,label:"\\fbox",body:t[0]}}}),mt({type:"enclose",names:["\\cancel","\\bcancel","\\xcancel","\\phase"],numArgs:1,handler(e,t){let r=e.parser,n=e.funcName;const o=t[0];return{type:"enclose",mode:r.mode,label:n,body:o}}}),mt({type:"enclose",names:["\\sout"],numArgs:1,allowedInText:!0,handler(e,t){let r=e.parser,n=e.funcName;"math"===r.mode&&r.settings.reportNonstrict("mathVsSout","LaTeX's \\sout works only in text mode");const o=t[0];return{type:"enclose",mode:r.mode,label:n,body:o}}}),mt({type:"enclose",names:["\\angl"],numArgs:1,argTypes:["hbox"],allowedInText:!1,handler(e,t){return{type:"enclose",mode:e.parser.mode,label:"\\angl",body:t[0]}}});const Zr={};function Kr(e){let t=e.type,r=e.names,n=e.props,o=e.handler,s=e.htmlBuilder,i=e.mathmlBuilder;const a={type:t,numArgs:n.numArgs||0,allowedInText:!1,numOptionalArgs:0,handler:o};for(let e=0;e{if(!e.parser.settings.displayMode)throw new n("{"+e.envName+"} can be used only in display mode.")},on=new Set(["gather","gather*"]);function sn(e){if(!e.includes("ed"))return!e.includes("*")}function an(e,t,r){let o=t.hskipBeforeAndAfter,s=t.addJot,i=t.cols,a=t.arraystretch,l=t.colSeparationType,c=t.autoTag,h=t.singleRow,m=t.emptySingleRow,u=t.maxNumCols,p=t.leqno;if(e.gullet.beginGroup(),h||e.gullet.macros.set("\\cr","\\\\\\relax"),!a){const t=e.gullet.expandMacroAsText("\\arraystretch");if(null==t)a=1;else if(a=parseFloat(t),!a||a<0)throw new n("Invalid \\arraystretch: "+t)}e.gullet.beginGroup();let d=[];const g=[d],f=[],b=[],y=null!=c?[]:void 0;function x(){c&&e.gullet.macros.set("\\@eqnsw","1",!0)}function w(){y&&(e.gullet.macros.get("\\df@tag")?(y.push(e.subparse([new tn("\\df@tag")])),e.gullet.macros.set("\\df@tag",void 0,!0)):y.push(Boolean(c)&&"1"===e.gullet.macros.get("\\@eqnsw")))}for(x(),b.push(rn(e));;){const t=e.parseExpression(!1,h?"\\end":"\\\\");e.gullet.endGroup(),e.gullet.beginGroup();let o={type:"ordgroup",mode:e.mode,body:t};r&&(o={type:"styling",mode:e.mode,style:r,resetFont:!0,body:[o]}),d.push(o);const s=e.fetch().text;if("&"===s){if(u&&d.length===u){if(h||l)throw new n("Too many tab characters: &",e.nextToken);e.settings.reportNonstrict("textEnv","Too few columns specified in the {array} column argument.")}e.consume()}else{if("\\end"===s){w(),1!==d.length||"styling"!==o.type||1!==o.body.length||"ordgroup"!==o.body[0].type||0!==o.body[0].body.length||!(g.length>1)&&m||Array.isArray(null==y?void 0:y[y.length-1])||c&&!0===(null==y?void 0:y[y.length-1])||g.pop(),b.length0&&(y+=.25),c.push({pos:y,isDashed:e[t]})}for(x(i[0]),r=0;r0&&(u+=b,ce))for(r=0;r=a)continue;var B,q;if(o>0||e.hskipBeforeAndAfter)i=null!=(B=null==(q=c)?void 0:q.pregap)?B:u,0!==i&&(z=Ye(["arraycolsep"],[]),z.style.width=N(i),k.push(z));const p=[];for(r=0;r0){const e=_e("katex-hline",t,h),r=_e("katex-hdashline",t,h),n=[{type:"elem",elem:O,shift:0}];for(;c.length>0;){const t=c.pop(),o=t.pos-w;t.isDashed?n.push({type:"elem",elem:r,shift:o}):n.push({type:"elem",elem:e,shift:o})}O=Ke({positionType:"individualShift",children:n})}if(0===A.length)return Ye(["mord"],[O],t);{const e=Ke({positionType:"individualShift",children:A}),r=Ye(["katex-tag"],[e],t);return $e([O,r])}},hn={c:"center ",l:"left ",r:"right "},mn=function(e,t){const r=[],n=new Bt("mtd",[],["mtr-glue"]),o=new Bt("mtd",[],["mml-eqn-num"]);for(let s=0;s0){const t=e.cols;let r="",n=!1,o=0,i=t.length;"separator"===t[0].type&&(a+="top ",o=1),"separator"===t[t.length-1].type&&(a+="bottom ",i-=1);for(let e=o;e0?"left ":"",a+=h[h.length-1].length>0?"right ":"";for(let e=1;e0&&c&&(n=1),r[e]={type:"align",align:t,pregap:n,postgap:0}}return s.colSeparationType=c?"align":"alignat",s};Kr({type:"array",names:["array","darray"],props:{numArgs:1},handler(e,t){const r=(ir(t[0])?[t[0]]:or(t[0],"ordgroup").body).map(function(e){const t=sr(e).text;if("lcr".includes(t))return{type:"align",align:t};if("|"===t)return{type:"separator",separator:"|"};if(":"===t)return{type:"separator",separator:":"};throw new n("Unknown column alignment: "+t,e)}),o={cols:r,hskipBeforeAndAfter:!0,maxNumCols:r.length};return an(e.parser,o,ln(e.envName))},htmlBuilder:cn,mathmlBuilder:mn}),Kr({type:"array",names:["matrix","pmatrix","bmatrix","Bmatrix","vmatrix","Vmatrix","matrix*","pmatrix*","bmatrix*","Bmatrix*","vmatrix*","Vmatrix*"],props:{numArgs:0},handler(e){const t={matrix:null,pmatrix:["(",")"],bmatrix:["[","]"],Bmatrix:["\\{","\\}"],vmatrix:["|","|"],Vmatrix:["\\Vert","\\Vert"]}[e.envName.replace("*","")];let r="c";const o={hskipBeforeAndAfter:!1,cols:[{type:"align",align:r}]};if("*"===e.envName.charAt(e.envName.length-1)){const t=e.parser;if(t.consumeSpaces(),"["===t.fetch().text){if(t.consume(),t.consumeSpaces(),r=t.fetch().text,!"lcr".includes(r))throw new n("Expected l or c or r",t.nextToken);t.consume(),t.consumeSpaces(),t.expect("]"),t.consume(),o.cols=[{type:"align",align:r}]}}const s=an(e.parser,o,ln(e.envName)),i=Math.max(0,...s.body.map(e=>e.length));return s.cols=new Array(i).fill({type:"align",align:r}),t?{type:"leftright",mode:e.mode,body:[s],left:t[0],right:t[1],rightColor:void 0}:s},htmlBuilder:cn,mathmlBuilder:mn}),Kr({type:"array",names:["smallmatrix"],props:{numArgs:0},handler(e){const t=an(e.parser,{arraystretch:.5},"script");return t.colSeparationType="small",t},htmlBuilder:cn,mathmlBuilder:mn}),Kr({type:"array",names:["subarray"],props:{numArgs:1},handler(e,t){const r=(ir(t[0])?[t[0]]:or(t[0],"ordgroup").body).map(function(e){const t=sr(e).text;if("lc".includes(t))return{type:"align",align:t};throw new n("Unknown column alignment: "+t,e)});if(r.length>1)throw new n("{subarray} can contain only one column");const o={cols:r,hskipBeforeAndAfter:!1,arraystretch:.5},s=an(e.parser,o,"script");if(s.body.length>0&&s.body[0].length>1)throw new n("{subarray} can contain only one column");return s},htmlBuilder:cn,mathmlBuilder:mn}),Kr({type:"array",names:["cases","dcases","rcases","drcases"],props:{numArgs:0},handler(e){const t=an(e.parser,{arraystretch:1.2,cols:[{type:"align",align:"l",pregap:0,postgap:1},{type:"align",align:"l",pregap:0,postgap:0}]},ln(e.envName));return{type:"leftright",mode:e.mode,body:[t],left:e.envName.includes("r")?".":"\\{",right:e.envName.includes("r")?"\\}":".",rightColor:void 0}},htmlBuilder:cn,mathmlBuilder:mn}),Kr({type:"array",names:["align","align*","aligned","split"],props:{numArgs:0},handler:un,htmlBuilder:cn,mathmlBuilder:mn}),Kr({type:"array",names:["gathered","gather","gather*"],props:{numArgs:0},handler(e){on.has(e.envName)&&nn(e);const t={cols:[{type:"align",align:"c"}],addJot:!0,colSeparationType:"gather",autoTag:sn(e.envName),emptySingleRow:!0,leqno:e.parser.settings.leqno};return an(e.parser,t,"display")},htmlBuilder:cn,mathmlBuilder:mn}),Kr({type:"array",names:["alignat","alignat*","alignedat"],props:{numArgs:1},handler:un,htmlBuilder:cn,mathmlBuilder:mn}),Kr({type:"array",names:["equation","equation*"],props:{numArgs:0},handler(e){nn(e);const t={autoTag:sn(e.envName),emptySingleRow:!0,singleRow:!0,maxNumCols:1,leqno:e.parser.settings.leqno};return an(e.parser,t,"display")},htmlBuilder:cn,mathmlBuilder:mn}),Kr({type:"array",names:["CD"],props:{numArgs:0},handler(e){return nn(e),function(e){const t=[];for(e.gullet.beginGroup(),e.gullet.macros.set("\\cr","\\\\\\relax"),e.gullet.beginGroup();;){t.push(e.parseExpression(!1,"\\\\")),e.gullet.endGroup(),e.gullet.beginGroup();const r=e.fetch().text;if("&"!==r&&"\\\\"!==r){if("\\end"===r){0===t[t.length-1].length&&t.pop();break}throw new n("Expected \\\\ or \\cr or \\end",e.nextToken)}e.consume()}let r=[];const o=[r];for(let s=0;sAV".includes(o))throw new n('Expected one of "<>AV=|." after @',i[t]);for(let e=0;e<2;e++){let r=!0;for(let a=t+1;a{let r=e.parser,n=e.funcName;const o=pt(t[0]),s=n in dn?dn[n]:n;return{type:"font",mode:r.mode,font:s.slice(1),body:o}},htmlBuilder:(e,t)=>{const r=e.font,n=t.withFont(r);return Mt(e.body,n)},mathmlBuilder:(e,t)=>{const r=e.font,n=t.withFont(r);return Vt(e.body,n)}}),mt({type:"mclass",names:["\\boldsymbol","\\bm"],numArgs:1,handler:(e,t)=>{let r=e.parser;const n=t[0];return{type:"mclass",mode:r.mode,mclass:ur(n),body:[{type:"font",mode:r.mode,font:"boldsymbol",body:n}],isCharacterBox:m(n)}}}),mt({type:"font",names:["\\rm","\\sf","\\tt","\\bf","\\it","\\cal"],numArgs:0,allowedInText:!0,handler:(e,t)=>{let r=e.parser,n=e.funcName,o=e.breakOnTokenText;const s=r.mode,i=r.parseExpression(!0,o);return{type:"font",mode:s,font:"math"+n.slice(1),body:{type:"ordgroup",mode:r.mode,body:i}}}});const gn=(e,t)=>{if(!t)return e;return{type:"styling",mode:e.mode,style:t,body:[e]}};mt({type:"genfrac",names:["\\cfrac","\\dfrac","\\frac","\\tfrac","\\dbinom","\\binom","\\tbinom","\\\\atopfrac","\\\\bracefrac","\\\\brackfrac"],numArgs:2,allowedInArgument:!0,handler:(e,t)=>{let r=e.parser,n=e.funcName;const o=t[0],s=t[1];let i,a=null,l=null;switch(n){case"\\cfrac":case"\\dfrac":case"\\frac":case"\\tfrac":i=!0;break;case"\\\\atopfrac":i=!1;break;case"\\dbinom":case"\\binom":case"\\tbinom":i=!1,a="(",l=")";break;case"\\\\bracefrac":i=!1,a="\\{",l="\\}";break;case"\\\\brackfrac":i=!1,a="[",l="]";break;default:throw new Error("Unrecognized genfrac command")}const c="\\cfrac"===n;let h=null;return c||n.startsWith("\\d")?h="display":n.startsWith("\\t")&&(h="text"),gn({type:"genfrac",mode:r.mode,numer:o,denom:s,continued:c,hasBarLine:i,leftDelim:a,rightDelim:l,barSize:null},h)},htmlBuilder:(e,t)=>{const r=t.style,n=r.fracNum(),o=r.fracDen();let s;s=t.havingStyle(n);const i=Mt(e.numer,s,t);if(e.continued){const e=8.5/t.fontMetrics().ptPerEm,r=3.5/t.fontMetrics().ptPerEm;i.height=i.height0?3*h:7*h,p=t.fontMetrics().denom1):(c>0?(m=t.fontMetrics().num2,u=h):(m=t.fontMetrics().num3,u=3*h),p=t.fontMetrics().denom2),l){const e=t.fontMetrics().axisHeight;m-i.depth-(e+.5*c){const r=new Bt("mfrac",[Vt(e.numer,t),Vt(e.denom,t)]);if(e.hasBarLine){if(e.barSize){const n=H(e.barSize,t);r.setAttribute("linethickness",N(n))}}else r.setAttribute("linethickness","0px");if(null!=e.leftDelim||null!=e.rightDelim){const t=[];if(null!=e.leftDelim){const r=new Bt("mo",[new qt(e.leftDelim.replace("\\",""))]);r.setAttribute("fence","true"),t.push(r)}if(t.push(r),null!=e.rightDelim){const r=new Bt("mo",[new qt(e.rightDelim.replace("\\",""))]);r.setAttribute("fence","true"),t.push(r)}return Ht(t)}return r}}),mt({type:"infix",names:["\\over","\\choose","\\atop","\\brace","\\brack"],numArgs:0,infix:!0,handler(e){let t,r=e.parser,n=e.funcName,o=e.token;switch(n){case"\\over":t="\\frac";break;case"\\choose":t="\\binom";break;case"\\atop":t="\\\\atopfrac";break;case"\\brace":t="\\\\bracefrac";break;case"\\brack":t="\\\\brackfrac";break;default:throw new Error("Unrecognized infix genfrac command")}return{type:"infix",mode:r.mode,replaceWith:t,token:o}}});const fn=["display","text","script","scriptscript"],bn=function(e){let t=null;return e.length>0&&(t=e,t="."===t?null:t),t};mt({type:"genfrac",names:["\\genfrac"],numArgs:6,allowedInArgument:!0,argTypes:["math","math","size","text","math","math"],handler(e,t){let r=e.parser;const n=t[4],o=t[5],s=pt(t[0]),i="atom"===s.type&&"open"===s.family?bn(s.text):null,a=pt(t[1]),l="atom"===a.type&&"close"===a.family?bn(a.text):null,c=or(t[2],"size");let h,m=null;c.isBlank?h=!0:(m=c.value,h=m.number>0);let u=null,p=t[3];if("ordgroup"===p.type){if(p.body.length>0){const e=or(p.body[0],"textord");u=fn[Number(e.text)]}}else p=or(p,"textord"),u=fn[Number(p.text)];return gn({type:"genfrac",mode:r.mode,numer:n,denom:o,continued:!1,hasBarLine:h,barSize:m,leftDelim:i,rightDelim:l},u)}}),mt({type:"infix",names:["\\above"],numArgs:1,argTypes:["size"],infix:!0,handler(e,t){let r=e.parser,n=(e.funcName,e.token);return{type:"infix",mode:r.mode,replaceWith:"\\\\abovefrac",size:or(t[0],"size").value,token:n}}}),mt({type:"genfrac",names:["\\\\abovefrac"],numArgs:3,argTypes:["math","size","math"],handler:(e,t)=>{let r=e.parser;e.funcName;const n=t[0],o=or(t[1],"infix").size;if(!o)throw new Error("\\\\abovefrac expected size, but got "+String(o));const s=t[2],i=o.number>0;return{type:"genfrac",mode:r.mode,numer:n,denom:s,continued:!1,hasBarLine:i,barSize:o,leftDelim:null,rightDelim:null}}});const yn=(e,t)=>{const r=t.style;let n,o;"supsub"===e.type?(n=e.sup?Mt(e.sup,t.havingStyle(r.sup()),t):Mt(e.sub,t.havingStyle(r.sub()),t),o=or(e.base,"horizBrace")):o=or(e,"horizBrace");const s=Mt(o.base,t.havingBaseStyle(S.DISPLAY)),i=tr(o,t);let a;if(a=o.isOver?Ke({positionType:"firstBaseline",children:[{type:"elem",elem:s},{type:"kern",size:.1},{type:"elem",elem:i,wrapperClasses:["svg-align"]}]}):Ke({positionType:"bottom",positionData:s.depth+.1+i.height,children:[{type:"elem",elem:i,wrapperClasses:["svg-align"]},{type:"kern",size:.1},{type:"elem",elem:s}]}),n){const e=Ye(["minner",o.isOver?"mover":"munder"],[a],t);a=o.isOver?Ke({positionType:"firstBaseline",children:[{type:"elem",elem:e},{type:"kern",size:.2},{type:"elem",elem:n}]}):Ke({positionType:"bottom",positionData:e.depth+.2+n.height+n.depth,children:[{type:"elem",elem:n},{type:"kern",size:.2},{type:"elem",elem:e}]})}return Ye(["minner",o.isOver?"mover":"munder"],[a],t)};mt({type:"horizBrace",names:["\\overbrace","\\underbrace","\\overbracket","\\underbracket"],numArgs:1,handler(e,t){let r=e.parser,n=e.funcName;return{type:"horizBrace",mode:r.mode,label:n,isOver:n.includes("\\over"),base:t[0]}},htmlBuilder:yn,mathmlBuilder:(e,t)=>{const r=Jt(e.label);return new Bt(e.isOver?"mover":"munder",[Vt(e.base,t),r])}}),mt({type:"href",names:["\\href"],numArgs:2,argTypes:["url","original"],allowedInText:!0,handler:(e,t)=>{let r=e.parser;const n=t[1],o=or(t[0],"url").url;return r.settings.isTrusted({command:"\\href",url:o})?{type:"href",mode:r.mode,href:o,body:dt(n)}:r.formatUnsupportedCmd("\\href")},htmlBuilder:(e,t)=>{const r=xt(e.body,t,!1);return function(e,t,r,n){const o=new U(e,t,r,n);return Xe(o),o}(e.href,[],r,t)},mathmlBuilder:(e,t)=>{let r=Pt(e.body,t);return r instanceof Bt||(r=new Bt("mrow",[r])),r.setAttribute("href",e.href),r}}),mt({type:"href",names:["\\url"],numArgs:1,argTypes:["url"],allowedInText:!0,handler:(e,t)=>{let r=e.parser;const n=or(t[0],"url").url;if(!r.settings.isTrusted({command:"\\url",url:n}))return r.formatUnsupportedCmd("\\url");const o=[];for(let e=0;e{let r=e.parser,o=e.funcName;e.token;const s=or(t[0],"raw").string,i=t[1];let a;r.settings.strict&&r.settings.reportNonstrict("htmlExtension","HTML extension is disabled on strict mode");const l={};switch(o){case"\\htmlClass":l.class=s,a={command:"\\htmlClass",class:s};break;case"\\htmlId":l.id=s,a={command:"\\htmlId",id:s};break;case"\\htmlStyle":l.style=s,a={command:"\\htmlStyle",style:s};break;case"\\htmlData":{const e="{,}",t=[];let r="";for(let n=0;n{const r=xt(e.body,t,!1),n=["enclosing"];e.attributes.class&&n.push(...e.attributes.class.trim().split(/\s+/));const o=Ye(n,r,t);for(const t of Object.entries(e.attributes)){const e=t[0],r=t[1];"class"!==e&&o.setAttribute(e,r)}return o},mathmlBuilder:(e,t)=>Pt(e.body,t)}),mt({type:"htmlmathml",names:["\\html@mathml"],numArgs:2,allowedInArgument:!0,allowedInText:!0,handler:(e,t)=>({type:"htmlmathml",mode:e.parser.mode,html:dt(t[0]),mathml:dt(t[1])}),htmlBuilder:(e,t)=>{const r=xt(e.html,t,!1);return $e(r)},mathmlBuilder:(e,t)=>Pt(e.mathml,t)});const xn=function(e){if(/^[-+]? *(\d+(\.\d*)?|\.\d+)$/.test(e))return{number:+e,unit:"bp"};{const t=/([-+]?) *(\d+(?:\.\d*)?|\.\d+) *([a-z]{2})/.exec(e);if(!t)throw new n("Invalid size: '"+e+"' in \\includegraphics");const r={number:+(t[1]+t[2]),unit:t[3]};if(!E(r))throw new n("Invalid unit: '"+r.unit+"' in \\includegraphics.");return r}};mt({type:"includegraphics",names:["\\includegraphics"],numArgs:1,numOptionalArgs:1,argTypes:["raw","url"],allowedInText:!1,handler:(e,t,r)=>{let o=e.parser,s={number:0,unit:"em"},i={number:.9,unit:"em"},a={number:0,unit:"em"},l="";if(r[0]){const e=or(r[0],"raw").string.split(",");for(let t=0;t{const r=H(e.height,t);let n=0;e.totalheight.number>0&&(n=H(e.totalheight,t)-r);let o=0;e.width.number>0&&(o=H(e.width,t));const s={height:N(r+n)};o>0&&(s.width=N(o)),n>0&&(s.verticalAlign=N(-n));const i=new X(e.src,e.alt,s);return i.height=r,i.depth=n,i},mathmlBuilder:(e,t)=>{const r=new Bt("mglyph",[]);r.setAttribute("alt",e.alt);const n=H(e.height,t);let o=0;if(e.totalheight.number>0&&(o=H(e.totalheight,t)-n,r.setAttribute("valign",N(-o))),r.setAttribute("height",N(n+o)),e.width.number>0){const n=H(e.width,t);r.setAttribute("width",N(n))}return r.setAttribute("src",e.src),r}}),mt({type:"kern",names:["\\kern","\\mkern","\\hskip","\\mskip"],numArgs:1,argTypes:["size"],primitive:!0,allowedInText:!0,handler(e,t){let r=e.parser,n=e.funcName;const o=or(t[0],"size");if(r.settings.strict){const e="m"===n[1],t="mu"===o.value.unit;e?(t||r.settings.reportNonstrict("mathVsTextUnits","LaTeX's "+n+" supports only mu units, not "+o.value.unit+" units"),"math"!==r.mode&&r.settings.reportNonstrict("mathVsTextUnits","LaTeX's "+n+" works only in math mode")):t&&r.settings.reportNonstrict("mathVsTextUnits","LaTeX's "+n+" doesn't support mu units")}return{type:"kern",mode:r.mode,dimension:o.value}},htmlBuilder(e,t){return Je(e.dimension,t)},mathmlBuilder(e,t){const r=H(e.dimension,t);return new It(r)}}),mt({type:"lap",names:["\\mathllap","\\mathrlap","\\mathclap"],numArgs:1,allowedInText:!0,handler:(e,t)=>{let r=e.parser,n=e.funcName;const o=t[0];return{type:"lap",mode:r.mode,alignment:n.slice(5),body:o}},htmlBuilder:(e,t)=>{let r;"clap"===e.alignment?(r=Ye([],[Mt(e.body,t)]),r=Ye(["katex-inner"],[r],t)):r=Ye(["katex-inner"],[Mt(e.body,t)]);const n=Ye(["katex-fix"],[]);let o=Ye([e.alignment],[r,n],t);const s=Ye(["katex-strut"]);return s.style.height=N(o.height+o.depth),o.depth&&(s.style.verticalAlign=N(-o.depth)),o.children.unshift(s),o=Ye(["katex-thinbox"],[o],t),Ye(["mord","katex-vbox"],[o],t)},mathmlBuilder:(e,t)=>{const r=new Bt("mpadded",[Vt(e.body,t)]);if("rlap"!==e.alignment){const t="llap"===e.alignment?"-1":"-0.5";r.setAttribute("lspace",t+"width")}return r.setAttribute("width","0px"),r}}),mt({type:"styling",names:["\\(","$"],numArgs:0,allowedInText:!0,allowedInMath:!1,handler(e,t){let r=e.funcName,n=e.parser;const o=n.mode;n.switchMode("math");const s="\\("===r?"\\)":"$",i=n.parseExpression(!1,s);return n.expect(s),n.switchMode(o),{type:"styling",mode:n.mode,style:"text",resetFont:!0,body:i}}}),mt({type:"text",names:["\\)","\\]"],numArgs:0,allowedInText:!0,allowedInMath:!1,handler(e,t){throw new n("Mismatched "+e.funcName)}});const wn=(e,t)=>{switch(t.style.size){case S.DISPLAY.size:return e.display;case S.TEXT.size:return e.text;case S.SCRIPT.size:return e.script;case S.SCRIPTSCRIPT.size:return e.scriptscript;default:return e.text}};mt({type:"mathchoice",names:["\\mathchoice"],numArgs:4,primitive:!0,handler:(e,t)=>({type:"mathchoice",mode:e.parser.mode,display:dt(t[0]),text:dt(t[1]),script:dt(t[2]),scriptscript:dt(t[3])}),htmlBuilder:(e,t)=>{const r=wn(e,t),n=xt(r,t,!1);return $e(n)},mathmlBuilder:(e,t)=>{const r=wn(e,t);return Pt(r,t)}});const vn=(e,t,r,n,o,s,i)=>{e=Ye([],[e]);const a=r&&m(r);let l,c,h;if(t){const e=Mt(t,n.havingStyle(o.sup()),n);c={elem:e,kern:Math.max(n.fontMetrics().bigOpSpacing1,n.fontMetrics().bigOpSpacing3-e.depth)}}if(r){const e=Mt(r,n.havingStyle(o.sub()),n);l={elem:e,kern:Math.max(n.fontMetrics().bigOpSpacing2,n.fontMetrics().bigOpSpacing4-e.height)}}if(c&&l){const t=n.fontMetrics().bigOpSpacing5+l.elem.height+l.elem.depth+l.kern+e.depth+i;h=Ke({positionType:"bottom",positionData:t,children:[{type:"kern",size:n.fontMetrics().bigOpSpacing5},{type:"elem",elem:l.elem,marginLeft:N(-s)},{type:"kern",size:l.kern},{type:"elem",elem:e},{type:"kern",size:c.kern},{type:"elem",elem:c.elem,marginLeft:N(s)},{type:"kern",size:n.fontMetrics().bigOpSpacing5}]})}else if(l){const t=e.height-i;h=Ke({positionType:"top",positionData:t,children:[{type:"kern",size:n.fontMetrics().bigOpSpacing5},{type:"elem",elem:l.elem,marginLeft:N(-s)},{type:"kern",size:l.kern},{type:"elem",elem:e}]})}else{if(!c)return e;{const t=e.depth+i;h=Ke({positionType:"bottom",positionData:t,children:[{type:"elem",elem:e},{type:"kern",size:c.kern},{type:"elem",elem:c.elem,marginLeft:N(s)},{type:"kern",size:n.fontMetrics().bigOpSpacing5}]})}}const u=[h];if(l&&0!==s&&!a){const e=Ye(["mspace"],[],n);e.style.marginRight=N(s),u.unshift(e)}return Ye(["mop","op-limits"],u,n)},kn=new Set(["\\smallint"]),zn=(e,t)=>{let r,n,o,s=!1;"supsub"===e.type?(r=e.sup,n=e.sub,o=or(e.base,"op"),s=!0):o=or(e,"op");const i=t.style;let a,l,c=!1;if(i.size===S.DISPLAY.size&&o.symbol&&!kn.has(o.name)&&(c=!0),o.symbol){const e=c?"Size2-Regular":"Size1-Regular";let r="";if("\\oiint"!==o.name&&"\\oiiint"!==o.name||(r=o.name.slice(1),o.name="oiint"===r?"\\iint":"\\iiint"),a=Pe(o.name,e,"math",t,["mop","op-symbol",c?"large-op":"small-op"]),l=a.italic,r.length>0){const e=rt(r+"Size"+(c?"2":"1"),t);a=Ke({positionType:"individualShift",children:[{type:"elem",elem:a,shift:0},{type:"elem",elem:e,shift:c?.08:0}]}),o.name="\\"+r,a.classes.unshift("mop"),a.italic=l}}else if(o.body){const e=xt(o.body,t,!0);1===e.length&&e[0]instanceof W?(a=e[0],a.classes[0]="mop"):a=Ye(["mop"],e,t)}else{const e=[];for(let r=1;r{let r=e.parser,n=e.funcName;return 1===n.length&&(n=Sn[n]),{type:"op",mode:r.mode,limits:!0,parentIsSupSub:!1,symbol:!0,name:n}},htmlBuilder:zn,mathmlBuilder:(e,t)=>{let r;if(e.symbol)r=new Bt("mo",[Et(e.name,e.mode)]),kn.has(e.name)&&r.setAttribute("largeop","false");else if(e.body)r=new Bt("mo",Ft(e.body,t));else{r=new Bt("mi",[new qt(e.name.slice(1))]);const t=new Bt("mo",[Et("\u2061","text")]);r=e.parentIsSupSub?new Bt("mrow",[r,t]):Ct([r,t])}return r}}),mt({type:"op",names:["\\mathop"],numArgs:1,primitive:!0,handler:(e,t)=>{let r=e.parser;const n=t[0];return{type:"op",mode:r.mode,limits:!1,parentIsSupSub:!1,symbol:!1,body:dt(n)}}});const Mn={"\u222b":"\\int","\u222c":"\\iint","\u222d":"\\iiint","\u222e":"\\oint","\u222f":"\\oiint","\u2230":"\\oiiint"};mt({type:"op",names:["\\arcsin","\\arccos","\\arctan","\\arctg","\\arcctg","\\arg","\\ch","\\cos","\\cosec","\\cosh","\\cot","\\cotg","\\coth","\\csc","\\ctg","\\cth","\\deg","\\dim","\\exp","\\hom","\\ker","\\lg","\\ln","\\log","\\sec","\\sin","\\sinh","\\sh","\\tan","\\tanh","\\tg","\\th"],numArgs:0,handler(e){let t=e.parser,r=e.funcName;return{type:"op",mode:t.mode,limits:!1,parentIsSupSub:!1,symbol:!1,name:r}}}),mt({type:"op",names:["\\det","\\gcd","\\inf","\\lim","\\max","\\min","\\Pr","\\sup"],numArgs:0,handler(e){let t=e.parser,r=e.funcName;return{type:"op",mode:t.mode,limits:!0,parentIsSupSub:!1,symbol:!1,name:r}}}),mt({type:"op",names:["\\int","\\iint","\\iiint","\\oint","\\oiint","\\oiiint","\u222b","\u222c","\u222d","\u222e","\u222f","\u2230"],numArgs:0,allowedInArgument:!0,handler(e){let t=e.parser,r=e.funcName;return 1===r.length&&(r=Mn[r]),{type:"op",mode:t.mode,limits:!1,parentIsSupSub:!1,symbol:!0,name:r}}});const An=(e,t)=>{let r,n,o,s,i=!1;if("supsub"===e.type?(r=e.sup,n=e.sub,o=or(e.base,"operatorname"),i=!0):o=or(e,"operatorname"),o.body.length>0){const e=o.body.map(e=>{const t="text"in e?e.text:void 0;return"string"==typeof t?{type:"textord",mode:e.mode,text:t}:e}),r=xt(e,t.withFont("mathrm"),!0);for(let e=0;e{let r=e.parser,n=e.funcName;const o=t[0];return{type:"operatorname",mode:r.mode,body:dt(o),alwaysHandleSupSub:"\\operatornamewithlimits"===n,limits:!1,parentIsSupSub:!1}},htmlBuilder:An,mathmlBuilder:(e,t)=>{let r=Ft(e.body,t.withFont("mathrm")),n=!0;for(let e=0;ee.toText()).join("");r=[new qt(e)]}const o=new Bt("mi",r);o.setAttribute("mathvariant","normal");const s=new Bt("mo",[Et("\u2061","text")]);return e.parentIsSupSub?new Bt("mrow",[o,s]):Ct([o,s])}}),Qr("\\operatorname","\\@ifstar\\operatornamewithlimits\\operatorname@"),ut({type:"ordgroup",htmlBuilder(e,t){return e.semisimple?$e(xt(e.body,t,!1)):Ye(["mord"],xt(e.body,t,!0),t)},mathmlBuilder(e,t){return Pt(e.body,t,!0)}}),mt({type:"overline",names:["\\overline"],numArgs:1,handler(e,t){let r=e.parser;const n=t[0];return{type:"overline",mode:r.mode,body:n}},htmlBuilder(e,t){const r=Mt(e.body,t.havingCrampedStyle()),n=_e("overline-line",t),o=t.fontMetrics().defaultRuleThickness,s=Ke({positionType:"firstBaseline",children:[{type:"elem",elem:r},{type:"kern",size:3*o},{type:"elem",elem:n},{type:"kern",size:o}]});return Ye(["mord","katex-overline"],[s],t)},mathmlBuilder(e,t){const r=new Bt("mo",[new qt("\u203e")]);r.setAttribute("stretchy","true");const n=new Bt("mover",[Vt(e.body,t),r]);return n.setAttribute("accent","true"),n}}),mt({type:"phantom",names:["\\phantom"],numArgs:1,allowedInText:!0,handler:(e,t)=>{let r=e.parser;const n=t[0];return{type:"phantom",mode:r.mode,body:dt(n)}},htmlBuilder:(e,t)=>{const r=xt(e.body,t.withPhantom(),!1);return $e(r)},mathmlBuilder:(e,t)=>{const r=Ft(e.body,t);return new Bt("mphantom",r)}}),Qr("\\hphantom","\\smash{\\phantom{#1}}"),mt({type:"vphantom",names:["\\vphantom"],numArgs:1,allowedInText:!0,handler:(e,t)=>{let r=e.parser;const n=t[0];return{type:"vphantom",mode:r.mode,body:n}},htmlBuilder:(e,t)=>{const r=Ye(["katex-inner"],[Mt(e.body,t.withPhantom())]),n=Ye(["katex-fix"],[]);return Ye(["mord","rlap"],[r,n],t)},mathmlBuilder:(e,t)=>{const r=Ft(dt(e.body),t),n=new Bt("mphantom",r),o=new Bt("mpadded",[n]);return o.setAttribute("width","0px"),o}}),mt({type:"raisebox",names:["\\raisebox"],numArgs:2,argTypes:["size","hbox"],allowedInText:!0,handler(e,t){let r=e.parser;const n=or(t[0],"size").value,o=t[1];return{type:"raisebox",mode:r.mode,dy:n,body:o}},htmlBuilder(e,t){const r=Mt(e.body,t),n=H(e.dy,t);return Ke({positionType:"shift",positionData:-n,children:[{type:"elem",elem:r}]})},mathmlBuilder(e,t){const r=new Bt("mpadded",[Vt(e.body,t)]),n=e.dy.number+e.dy.unit;return r.setAttribute("voffset",n),r}});const Tn=(e,t)=>({type:"reflectbox",mode:e.parser.mode,body:t[0]});function Cn(e,t,r){const n=xt(e,t,!1),o=t.sizeMultiplier/r.sizeMultiplier;for(let e=0;e=0?a.setAttribute("height",N(o)):(a.setAttribute("height",N(o)),a.setAttribute("depth",N(-o))),a.setAttribute("voffset",N(o)),a}});const Bn=["\\tiny","\\sixptsize","\\scriptsize","\\footnotesize","\\small","\\normalsize","\\large","\\Large","\\LARGE","\\huge","\\Huge"];mt({type:"sizing",names:Bn,numArgs:0,allowedInText:!0,handler:(e,t)=>{let r=e.breakOnTokenText,n=e.funcName,o=e.parser;const s=o.parseExpression(!1,r);return{type:"sizing",mode:o.mode,size:Bn.indexOf(n)+1,body:s}},htmlBuilder:(e,t)=>{const r=t.havingSize(e.size);return Cn(e.body,r,t)},mathmlBuilder:(e,t)=>{const r=t.havingSize(e.size),n=Ft(e.body,r),o=new Bt("mstyle",n);return o.setAttribute("mathsize",N(r.sizeMultiplier)),o}}),mt({type:"smash",names:["\\smash"],numArgs:1,numOptionalArgs:1,allowedInText:!0,handler:(e,t,r)=>{let n=e.parser,o=!1,s=!1;const i=r[0]&&or(r[0],"ordgroup");if(i){let e;for(let t=0;t{const r=Ye([],[Mt(e.body,t)]);if(!e.smashHeight&&!e.smashDepth)return r;if(e.smashHeight&&(r.height=0),e.smashDepth&&(r.depth=0),e.smashHeight&&e.smashDepth)return Ye(["mord","katex-smash"],[r],t);if(r.children)for(let t=0;t{const r=new Bt("mpadded",[Vt(e.body,t)]);return e.smashHeight&&r.setAttribute("height","0px"),e.smashDepth&&r.setAttribute("depth","0px"),r}}),mt({type:"sqrt",names:["\\sqrt"],numArgs:1,numOptionalArgs:1,handler(e,t,r){let n=e.parser;const o=r[0],s=t[0];return{type:"sqrt",mode:n.mode,body:s,index:o}},htmlBuilder(e,t){let r=Mt(e.body,t.havingCrampedStyle());0===r.height&&(r.height=t.fontMetrics().xHeight),r=Ze(r,t);const n=t.fontMetrics().defaultRuleThickness;let o=n;t.style.idr.height+r.depth+s&&(s=(s+h-r.height-r.depth)/2);const m=a.height-r.height-s-l;r.style.paddingLeft=N(c);const u=Ke({positionType:"firstBaseline",children:[{type:"elem",elem:r,wrapperClasses:["svg-align"]},{type:"kern",size:-(r.height+m)},{type:"elem",elem:a},{type:"kern",size:l}]});if(e.index){const r=t.havingStyle(S.SCRIPTSCRIPT),n=Mt(e.index,r,t),o=.6*(u.height-u.depth),s=Ke({positionType:"shift",positionData:-o,children:[{type:"elem",elem:n}]}),i=Ye(["katex-root"],[s]);return Ye(["mord","sqrt"],[i,u],t)}return Ye(["mord","sqrt"],[u],t)},mathmlBuilder(e,t){const r=e.body,n=e.index;return n?new Bt("mroot",[Vt(r,t),Vt(n,t)]):new Bt("msqrt",[Vt(r,t)])}});const qn={display:S.DISPLAY,text:S.TEXT,script:S.SCRIPT,scriptscript:S.SCRIPTSCRIPT};mt({type:"styling",names:["\\displaystyle","\\textstyle","\\scriptstyle","\\scriptscriptstyle"],numArgs:0,allowedInText:!0,primitive:!0,handler(e,t){let r=e.breakOnTokenText,n=e.funcName,o=e.parser;const s=o.parseExpression(!0,r),i=n.slice(1,n.length-5);if(!(i in qn))throw new Error("Unknown style: "+i);return{type:"styling",mode:o.mode,style:i,body:s}},htmlBuilder(e,t){const r=qn[e.style];let n=t.havingStyle(r);return e.resetFont&&(n=n.withFont("")),Cn(e.body,n,t)},mathmlBuilder(e,t){const r=qn[e.style];let n=t.havingStyle(r);e.resetFont&&(n=n.withFont(""));const o=Ft(e.body,n),s=new Bt("mstyle",o),i={display:["0","true"],text:["0","false"],script:["1","false"],scriptscript:["2","false"]}[e.style];return s.setAttribute("scriptlevel",i[0]),s.setAttribute("displaystyle",i[1]),s}});ut({type:"supsub",htmlBuilder(e,t){const r=function(e,t){const r=e.base;if(r)return"op"===r.type?r.limits&&(t.style.size===S.DISPLAY.size||r.alwaysHandleSupSub)?zn:null:"operatorname"===r.type?r.alwaysHandleSupSub&&(t.style.size===S.DISPLAY.size||r.limits)?An:null:"accent"===r.type?m(r.base)?cr:null:"horizBrace"===r.type&&!e.sub===r.isOver?yn:null;return null}(e,t);if(r)return r(e,t);const n=e.base,o=e.sup,s=e.sub,i=Mt(n,t);let a,l;const c=t.fontMetrics();let h=0,u=0;const p=n&&m(n);if(o){const e=t.havingStyle(t.style.sup());a=Mt(o,e,t),p||(h=i.height-e.fontMetrics().supDrop*e.sizeMultiplier/t.sizeMultiplier)}if(s){const e=t.havingStyle(t.style.sub());l=Mt(s,e,t),p||(u=i.depth+e.fontMetrics().subDrop*e.sizeMultiplier/t.sizeMultiplier)}let d;d=t.style===S.DISPLAY?c.sup1:t.style.cramped?c.sup3:c.sup2;const g=t.sizeMultiplier,f=N(.5/c.ptPerEm/g);let b,y=null;if(l){const t=e.base&&"op"===e.base.type&&e.base.name&&("\\oiint"===e.base.name||"\\oiiint"===e.base.name);var x;if(i instanceof W||t)y=N(-(null!=(x=i.italic)?x:0))}if(a&&l){h=Math.max(h,d,a.depth+.25*c.xHeight),u=Math.max(u,c.sub2);const e=4*c.defaultRuleThickness;if(h-a.depth-(l.height-u)0&&(h+=t,u-=t)}b=Ke({positionType:"individualShift",children:[{type:"elem",elem:l,shift:u,marginRight:f,marginLeft:y},{type:"elem",elem:a,shift:-h,marginRight:f}]})}else if(l){u=Math.max(u,c.sub1,l.height-.8*c.xHeight);b=Ke({positionType:"shift",positionData:u,children:[{type:"elem",elem:l,marginLeft:y,marginRight:f}]})}else{if(!a)throw new Error("supsub must have either sup or sub.");h=Math.max(h,d,a.depth+.25*c.xHeight),b=Ke({positionType:"shift",positionData:-h,children:[{type:"elem",elem:a,marginRight:f}]})}const w=zt(i,"right")||"mord";return Ye([w],[i,Ye(["msupsub"],[b])],t)},mathmlBuilder(e,t){let r,n,o=!1;e.base&&"horizBrace"===e.base.type&&(n=!!e.sup,n===e.base.isOver&&(o=!0,r=e.base.isOver)),!e.base||"op"!==e.base.type&&"operatorname"!==e.base.type||(e.base.parentIsSupSub=!0);const s=[Vt(e.base,t)];let i;if(e.sub&&s.push(Vt(e.sub,t)),e.sup&&s.push(Vt(e.sup,t)),o)i=r?"mover":"munder";else if(e.sub)if(e.sup){const r=e.base;i=r&&"op"===r.type&&r.limits&&t.style===S.DISPLAY||r&&"operatorname"===r.type&&r.alwaysHandleSupSub&&(t.style===S.DISPLAY||r.limits)?"munderover":"msubsup"}else{const r=e.base;i=r&&"op"===r.type&&r.limits&&(t.style===S.DISPLAY||r.alwaysHandleSupSub)||r&&"operatorname"===r.type&&r.alwaysHandleSupSub&&(r.limits||t.style===S.DISPLAY)?"munder":"msub"}else{const r=e.base;i=r&&"op"===r.type&&r.limits&&(t.style===S.DISPLAY||r.alwaysHandleSupSub)||r&&"operatorname"===r.type&&r.alwaysHandleSupSub&&(r.limits||t.style===S.DISPLAY)?"mover":"msup"}return new Bt(i,s)}}),ut({type:"atom",htmlBuilder(e,t){return Ve(e.text,e.mode,t,["m"+e.family])},mathmlBuilder(e,t){const r=new Bt("mo",[Et(e.text,e.mode)]);if("bin"===e.family){const n=Dt(e,t);"bold-italic"===n&&r.setAttribute("mathvariant",n)}else"punct"===e.family?r.setAttribute("separator","true"):"open"!==e.family&&"close"!==e.family||r.setAttribute("stretchy","false");return r}});const In={mi:"italic",mn:"normal",mtext:"normal"};ut({type:"mathord",htmlBuilder(e,t){return Ge(e,t)},mathmlBuilder(e,t){const r=new Bt("mi",[Et(e.text,e.mode,t)]),n=Dt(e,t)||"italic";return n!==In[r.type]&&r.setAttribute("mathvariant",n),r}}),ut({type:"textord",htmlBuilder(e,t){return Ge(e,t)},mathmlBuilder(e,t){const r=Et(e.text,e.mode,t),n=Dt(e,t)||"normal";let o;return o="text"===e.mode?new Bt("mtext",[r]):/[0-9]/.test(e.text)?new Bt("mn",[r]):"\\prime"===e.text?new Bt("mo",[r]):new Bt("mi",[r]),n!==In[o.type]&&o.setAttribute("mathvariant",n),o}});const Rn=new Map([["\\nobreak","nobreak"],["\\allowbreak","allowbreak"]]),On=new Map([[" ",{}],["\\ ",{}],["~",{className:"nobreak"}],["\\space",{}],["\\nobreakspace",{className:"nobreak"}]]);ut({type:"spacing",htmlBuilder(e,t){const r=On.get(e.text),o=Rn.get(e.text);if(r){const n=r.className||"";if("text"===e.mode){const r=Ge(e,t);return r.classes.push(n),r}return Ye(["mspace",n],[Ve(e.text,e.mode,t)],t)}if(o)return Ye(["mspace",o],[],t);throw new n('Unknown type of space "'+e.text+'"')},mathmlBuilder(e,t){let r;if(!On.has(e.text)){if(Rn.has(e.text))return new Bt("mspace");throw new n('Unknown type of space "'+e.text+'"')}return r=new Bt("mtext",[new qt("\xa0")]),r}});const En=()=>{const e=new Bt("mtd",[]);return e.setAttribute("width","50%"),e};ut({type:"tag",mathmlBuilder(e,t){const r=new Bt("mtable",[new Bt("mtr",[En(),new Bt("mtd",[Pt(e.body,t)]),En(),new Bt("mtd",[Pt(e.tag,t)])])]);return r.setAttribute("width","100%"),r}});const Hn={"\\text":void 0,"\\textrm":"textrm","\\textsf":"textsf","\\texttt":"texttt","\\textnormal":"textrm"},Nn={"\\textbf":"textbf","\\textmd":"textmd"},Dn={"\\textit":"textit","\\textup":"textup"},Ln=(e,t)=>{const r=e.font;return r?Hn[r]?t.withTextFontFamily(Hn[r]):Nn[r]?t.withTextFontWeight(Nn[r]):"\\emph"===r?"textit"===t.fontShape?t.withTextFontShape("textup"):t.withTextFontShape("textit"):t.withTextFontShape(Dn[r]):t};mt({type:"text",names:["\\text","\\textrm","\\textsf","\\texttt","\\textnormal","\\textbf","\\textmd","\\textit","\\textup","\\emph"],numArgs:1,argTypes:["text"],allowedInArgument:!0,allowedInText:!0,handler(e,t){let r=e.parser,n=e.funcName;const o=t[0];return{type:"text",mode:r.mode,body:dt(o),font:n}},htmlBuilder(e,t){const r=Ln(e,t),n=xt(e.body,r,!0);return Ye(["mord","text"],n,r)},mathmlBuilder(e,t){const r=Ln(e,t);return Pt(e.body,r)}}),mt({type:"underline",names:["\\underline"],numArgs:1,allowedInText:!0,handler(e,t){return{type:"underline",mode:e.parser.mode,body:t[0]}},htmlBuilder(e,t){const r=Mt(e.body,t),n=_e("underline-line",t),o=t.fontMetrics().defaultRuleThickness,s=Ke({positionType:"top",positionData:r.height,children:[{type:"kern",size:o},{type:"elem",elem:n},{type:"kern",size:3*o},{type:"elem",elem:r}]});return Ye(["mord","katex-underline"],[s],t)},mathmlBuilder(e,t){const r=new Bt("mo",[new qt("\u203e")]);r.setAttribute("stretchy","true");const n=new Bt("munder",[Vt(e.body,t),r]);return n.setAttribute("accentunder","true"),n}}),mt({type:"vcenter",names:["\\vcenter"],numArgs:1,argTypes:["original"],allowedInText:!1,handler(e,t){return{type:"vcenter",mode:e.parser.mode,body:t[0]}},htmlBuilder(e,t){const r=Mt(e.body,t),n=t.fontMetrics().axisHeight,o=.5*(r.height-n-(r.depth+n));return Ke({positionType:"shift",positionData:o,children:[{type:"elem",elem:r}]})},mathmlBuilder(e,t){const r=new Bt("mpadded",[Vt(e.body,t)],["vcenter"]);return new Bt("mrow",[r])}}),mt({type:"verb",names:["\\verb"],numArgs:0,allowedInText:!0,handler(e,t,r){throw new n("\\verb ended by end of line instead of matching delimiter")},htmlBuilder(e,t){const r=Fn(e),n=[],o=t.havingStyle(t.style.text());for(let t=0;te.body.replace(/ /g,e.star?"\u2423":"\xa0");var Pn=lt;const Vn="[ \r\n\t]",Gn="(\\\\[a-zA-Z@]+)"+Vn+"*",jn="[\u0300-\u036f]",Un=new RegExp(jn+"+$"),Xn="("+Vn+"+)|\\\\(\n|[ \r\t]+\n?)[ \r\t]*|([!-\\[\\]-\u2027\u202a-\ud7ff\uf900-\uffff]"+jn+"*|[\ud800-\udbff][\udc00-\udfff]"+jn+"*|\\\\verb\\*([^]).*?\\4|\\\\verb([^*a-zA-Z]).*?\\5|"+Gn+"|\\\\[^\ud800-\udfff])";class Yn{constructor(e,t){this.input=void 0,this.settings=void 0,this.tokenRegex=void 0,this.catcodes=void 0,this.input=e,this.settings=t,this.tokenRegex=new RegExp(Xn,"g"),this.catcodes={"%":14,"~":13}}setCatcode(e,t){this.catcodes[e]=t}lex(){const e=this.input,t=this.tokenRegex.lastIndex;if(t===e.length)return new tn("EOF",new en(this,t,t));const r=this.tokenRegex.exec(e);if(null===r||r.index!==t)throw new n("Unexpected character: '"+e[t]+"'",new tn(e[t],new en(this,t,t+1)));const o=r[6]||r[3]||(r[2]?"\\ ":" ");if(14===this.catcodes[o]){const t=e.indexOf("\n",this.tokenRegex.lastIndex);return-1===t?(this.tokenRegex.lastIndex=e.length,this.settings.reportNonstrict("commentAtEnd","% comment has no terminating newline; LaTeX would fail because of commenting the end of math mode (e.g. $)")):this.tokenRegex.lastIndex=t+1,this.lex()}return new tn(o,new en(this,t,this.tokenRegex.lastIndex))}}class Wn{constructor(e,t){void 0===e&&(e={}),void 0===t&&(t={}),this.current=void 0,this.builtins=void 0,this.undefStack=void 0,this.current=t,this.builtins=e,this.undefStack=[]}beginGroup(){this.undefStack.push({})}endGroup(){if(0===this.undefStack.length)throw new n("Unbalanced namespace destruction: attempt to pop global namespace; please report this as a bug");const e=this.undefStack.pop();for(const t of Object.keys(e))void 0===e[t]?delete this.current[t]:this.current[t]=e[t]}endGroups(){for(;this.undefStack.length>0;)this.endGroup()}has(e){return Object.prototype.hasOwnProperty.call(this.current,e)||Object.prototype.hasOwnProperty.call(this.builtins,e)}get(e){return Object.prototype.hasOwnProperty.call(this.current,e)?this.current[e]:Object.prototype.hasOwnProperty.call(this.builtins,e)?this.builtins[e]:void 0}set(e,t,r){if(void 0===r&&(r=!1),r){for(let t=0;t0&&(this.undefStack[this.undefStack.length-1][e]=t)}else{const t=this.undefStack[this.undefStack.length-1];t&&!Object.prototype.hasOwnProperty.call(t,e)&&(t[e]=Object.prototype.hasOwnProperty.call(this.current,e)?this.current[e]:void 0)}null==t?delete this.current[e]:this.current[e]=t}}var _n=Jr;Qr("\\noexpand",function(e){const t=e.popToken();return e.isExpandable(t.text)&&(t.noexpand=!0,t.treatAsRelax=!0),{tokens:[t],numArgs:0}}),Qr("\\expandafter",function(e){const t=e.popToken();return e.expandOnce(!0),{tokens:[t],numArgs:0}}),Qr("\\@firstoftwo",function(e){return{tokens:e.consumeArgs(2)[0],numArgs:0}}),Qr("\\@secondoftwo",function(e){return{tokens:e.consumeArgs(2)[1],numArgs:0}}),Qr("\\@ifnextchar",function(e){const t=e.consumeArgs(3);e.consumeSpaces();const r=e.future();return 1===t[0].length&&t[0][0].text===r.text?{tokens:t[1],numArgs:0}:{tokens:t[2],numArgs:0}}),Qr("\\@ifstar","\\@ifnextchar *{\\@firstoftwo{#1}}"),Qr("\\TextOrMath",function(e){const t=e.consumeArgs(2);return"text"===e.mode?{tokens:t[0],numArgs:0}:{tokens:t[1],numArgs:0}});const $n={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,a:10,A:10,b:11,B:11,c:12,C:12,d:13,D:13,e:14,E:14,f:15,F:15};Qr("\\char",function(e){let t,r=e.popToken(),o=0;if("'"===r.text)t=8,r=e.popToken();else if('"'===r.text)t=16,r=e.popToken();else if("`"===r.text)if(r=e.popToken(),"\\"===r.text[0])o=r.text.charCodeAt(1);else{if("EOF"===r.text)throw new n("\\char` missing argument");o=r.text.charCodeAt(0)}else t=10;if(t){if(o=$n[r.text],null==o||o>=t)throw new n("Invalid base-"+t+" digit "+r.text);let s;for(;null!=(s=$n[e.future().text])&&s{let s=e.consumeArg().tokens;if(1!==s.length)throw new n("\\newcommand's first argument must be a macro name");const i=s[0].text,a=e.isDefined(i);if(a&&!t)throw new n("\\newcommand{"+i+"} attempting to redefine "+i+"; use \\renewcommand");if(!a&&!r)throw new n("\\renewcommand{"+i+"} when command "+i+" does not yet exist; use \\newcommand");let l=0;if(s=e.consumeArg().tokens,1===s.length&&"["===s[0].text){let t="",r=e.expandNextToken();for(;"]"!==r.text&&"EOF"!==r.text;)t+=r.text,r=e.expandNextToken();if(!t.match(/^\s*[0-9]+\s*$/))throw new n("Invalid number of arguments: "+t);l=parseInt(t),s=e.consumeArg().tokens}return a&&o||e.macros.set(i,{tokens:s,numArgs:l}),""};Qr("\\newcommand",e=>Zn(e,!1,!0,!1)),Qr("\\renewcommand",e=>Zn(e,!0,!1,!1)),Qr("\\providecommand",e=>Zn(e,!0,!0,!0)),Qr("\\message",e=>{const t=e.consumeArgs(1)[0];return console.log(t.reverse().map(e=>e.text).join("")),""}),Qr("\\errmessage",e=>{const t=e.consumeArgs(1)[0];return console.error(t.reverse().map(e=>e.text).join("")),""}),Qr("\\show",e=>{const t=e.popToken(),r=t.text;return console.log(t,e.macros.get(r),Pn[r],ne.math[r],ne.text[r]),""}),Qr("\\bgroup","{"),Qr("\\egroup","}"),Qr("~","\\nobreakspace"),Qr("\\lq","`"),Qr("\\rq","'"),Qr("\\aa","\\r a"),Qr("\\AA","\\r A"),Qr("\\textcopyright","\\html@mathml{\\textcircled{c}}{\\char`\xa9}"),Qr("\\copyright","\\TextOrMath{\\textcopyright}{\\text{\\textcopyright}}"),Qr("\\textregistered","\\html@mathml{\\textcircled{\\scriptsize R}}{\\char`\xae}"),Qr("\u212c","\\mathscr{B}"),Qr("\u2130","\\mathscr{E}"),Qr("\u2131","\\mathscr{F}"),Qr("\u210b","\\mathscr{H}"),Qr("\u2110","\\mathscr{I}"),Qr("\u2112","\\mathscr{L}"),Qr("\u2133","\\mathscr{M}"),Qr("\u211b","\\mathscr{R}"),Qr("\u212d","\\mathfrak{C}"),Qr("\u210c","\\mathfrak{H}"),Qr("\u2128","\\mathfrak{Z}"),Qr("\\Bbbk","\\Bbb{k}"),Qr("\\llap","\\mathllap{\\textrm{#1}}"),Qr("\\rlap","\\mathrlap{\\textrm{#1}}"),Qr("\\clap","\\mathclap{\\textrm{#1}}"),Qr("\\mathstrut","\\vphantom{(}"),Qr("\\underbar","\\underline{\\text{#1}}"),Qr("\\not",'\\html@mathml{\\mathrel{\\mathrlap\\@not}\\nobreak}{\\char"338}'),Qr("\\neq","\\html@mathml{\\mathrel{\\not=}}{\\mathrel{\\char`\u2260}}"),Qr("\\ne","\\neq"),Qr("\u2260","\\neq"),Qr("\\notin","\\html@mathml{\\mathrel{{\\in}\\mathllap{/\\mskip1mu}}}{\\mathrel{\\char`\u2209}}"),Qr("\u2209","\\notin"),Qr("\u2258","\\html@mathml{\\mathrel{=\\kern{-1em}\\raisebox{0.4em}{$\\scriptsize\\frown$}}}{\\mathrel{\\char`\u2258}}"),Qr("\u2259","\\html@mathml{\\stackrel{\\tiny\\wedge}{=}}{\\mathrel{\\char`\u2258}}"),Qr("\u225a","\\html@mathml{\\stackrel{\\tiny\\vee}{=}}{\\mathrel{\\char`\u225a}}"),Qr("\u225b","\\html@mathml{\\stackrel{\\scriptsize\\star}{=}}{\\mathrel{\\char`\u225b}}"),Qr("\u225d","\\html@mathml{\\stackrel{\\tiny\\mathrm{def}}{=}}{\\mathrel{\\char`\u225d}}"),Qr("\u225e","\\html@mathml{\\stackrel{\\tiny\\mathrm{m}}{=}}{\\mathrel{\\char`\u225e}}"),Qr("\u225f","\\html@mathml{\\stackrel{\\tiny?}{=}}{\\mathrel{\\char`\u225f}}"),Qr("\u27c2","\\perp"),Qr("\u203c","\\mathclose{!\\mkern-0.8mu!}"),Qr("\u220c","\\notni"),Qr("\u231c","\\ulcorner"),Qr("\u231d","\\urcorner"),Qr("\u231e","\\llcorner"),Qr("\u231f","\\lrcorner"),Qr("\xa9","\\copyright"),Qr("\xae","\\textregistered"),Qr("\\ulcorner",'\\html@mathml{\\@ulcorner}{\\mathop{\\char"231c}}'),Qr("\\urcorner",'\\html@mathml{\\@urcorner}{\\mathop{\\char"231d}}'),Qr("\\llcorner",'\\html@mathml{\\@llcorner}{\\mathop{\\char"231e}}'),Qr("\\lrcorner",'\\html@mathml{\\@lrcorner}{\\mathop{\\char"231f}}'),Qr("\\vdots","{\\varvdots\\rule{0pt}{15pt}}"),Qr("\u22ee","\\vdots"),Qr("\\varGamma","\\mathit{\\Gamma}"),Qr("\\varDelta","\\mathit{\\Delta}"),Qr("\\varTheta","\\mathit{\\Theta}"),Qr("\\varLambda","\\mathit{\\Lambda}"),Qr("\\varXi","\\mathit{\\Xi}"),Qr("\\varPi","\\mathit{\\Pi}"),Qr("\\varSigma","\\mathit{\\Sigma}"),Qr("\\varUpsilon","\\mathit{\\Upsilon}"),Qr("\\varPhi","\\mathit{\\Phi}"),Qr("\\varPsi","\\mathit{\\Psi}"),Qr("\\varOmega","\\mathit{\\Omega}"),Qr("\\substack","\\begin{subarray}{c}#1\\end{subarray}"),Qr("\\colon","\\nobreak\\mskip2mu\\mathpunct{}\\mathchoice{\\mkern-3mu}{\\mkern-3mu}{}{}{:}\\mskip6mu\\relax"),Qr("\\boxed","\\fbox{$\\displaystyle{#1}$}"),Qr("\\iff","\\DOTSB\\;\\Longleftrightarrow\\;"),Qr("\\implies","\\DOTSB\\;\\Longrightarrow\\;"),Qr("\\impliedby","\\DOTSB\\;\\Longleftarrow\\;"),Qr("\\dddot","{\\overset{\\raisebox{-0.1ex}{\\normalsize ...}}{#1}}"),Qr("\\ddddot","{\\overset{\\raisebox{-0.1ex}{\\normalsize ....}}{#1}}");const Kn={",":"\\dotsc","\\not":"\\dotsb","+":"\\dotsb","=":"\\dotsb","<":"\\dotsb",">":"\\dotsb","-":"\\dotsb","*":"\\dotsb",":":"\\dotsb","\\DOTSB":"\\dotsb","\\coprod":"\\dotsb","\\bigvee":"\\dotsb","\\bigwedge":"\\dotsb","\\biguplus":"\\dotsb","\\bigcap":"\\dotsb","\\bigcup":"\\dotsb","\\prod":"\\dotsb","\\sum":"\\dotsb","\\bigotimes":"\\dotsb","\\bigoplus":"\\dotsb","\\bigodot":"\\dotsb","\\bigsqcup":"\\dotsb","\\And":"\\dotsb","\\longrightarrow":"\\dotsb","\\Longrightarrow":"\\dotsb","\\longleftarrow":"\\dotsb","\\Longleftarrow":"\\dotsb","\\longleftrightarrow":"\\dotsb","\\Longleftrightarrow":"\\dotsb","\\mapsto":"\\dotsb","\\longmapsto":"\\dotsb","\\hookrightarrow":"\\dotsb","\\doteq":"\\dotsb","\\mathbin":"\\dotsb","\\mathrel":"\\dotsb","\\relbar":"\\dotsb","\\Relbar":"\\dotsb","\\xrightarrow":"\\dotsb","\\xleftarrow":"\\dotsb","\\DOTSI":"\\dotsi","\\int":"\\dotsi","\\oint":"\\dotsi","\\iint":"\\dotsi","\\iiint":"\\dotsi","\\iiiint":"\\dotsi","\\idotsint":"\\dotsi","\\DOTSX":"\\dotsx"},Jn=new Set(["bin","rel"]);Qr("\\dots",function(e){let t="\\dotso";const r=e.expandAfterFuture().text;return r in Kn?t=Kn[r]:("\\not"===r.slice(0,4)||r in ne.math&&Jn.has(ne.math[r].group))&&(t="\\dotsb"),t});const Qn={")":!0,"]":!0,"\\rbrack":!0,"\\}":!0,"\\rbrace":!0,"\\rangle":!0,"\\rceil":!0,"\\rfloor":!0,"\\rgroup":!0,"\\rmoustache":!0,"\\right":!0,"\\bigr":!0,"\\biggr":!0,"\\Bigr":!0,"\\Biggr":!0,$:!0,";":!0,".":!0,",":!0};Qr("\\dotso",function(e){return e.future().text in Qn?"\\ldots\\,":"\\ldots"}),Qr("\\dotsc",function(e){const t=e.future().text;return t in Qn&&","!==t?"\\ldots\\,":"\\ldots"}),Qr("\\cdots",function(e){return e.future().text in Qn?"\\@cdots\\,":"\\@cdots"}),Qr("\\dotsb","\\cdots"),Qr("\\dotsm","\\cdots"),Qr("\\dotsi","\\!\\cdots"),Qr("\\dotsx","\\ldots\\,"),Qr("\\DOTSI","\\relax"),Qr("\\DOTSB","\\relax"),Qr("\\DOTSX","\\relax"),Qr("\\tmspace","\\TextOrMath{\\kern#1#3}{\\mskip#1#2}\\relax"),Qr("\\,","\\tmspace+{3mu}{.1667em}"),Qr("\\thinspace","\\,"),Qr("\\>","\\mskip{4mu}"),Qr("\\:","\\tmspace+{4mu}{.2222em}"),Qr("\\medspace","\\:"),Qr("\\;","\\tmspace+{5mu}{.2777em}"),Qr("\\thickspace","\\;"),Qr("\\!","\\tmspace-{3mu}{.1667em}"),Qr("\\negthinspace","\\!"),Qr("\\negmedspace","\\tmspace-{4mu}{.2222em}"),Qr("\\negthickspace","\\tmspace-{5mu}{.277em}"),Qr("\\enspace","\\kern.5em "),Qr("\\enskip","\\hskip.5em\\relax"),Qr("\\quad","\\hskip1em\\relax"),Qr("\\qquad","\\hskip2em\\relax"),Qr("\\tag","\\@ifstar\\tag@literal\\tag@paren"),Qr("\\tag@paren","\\tag@literal{({#1})}"),Qr("\\tag@literal",e=>{if(e.macros.get("\\df@tag"))throw new n("Multiple \\tag");return"\\gdef\\df@tag{\\text{#1}}"}),Qr("\\bmod","\\mathchoice{\\mskip1mu}{\\mskip1mu}{\\mskip5mu}{\\mskip5mu}\\mathbin{\\rm mod}\\mathchoice{\\mskip1mu}{\\mskip1mu}{\\mskip5mu}{\\mskip5mu}"),Qr("\\pod","\\allowbreak\\mathchoice{\\mkern18mu}{\\mkern8mu}{\\mkern8mu}{\\mkern8mu}(#1)"),Qr("\\pmod","\\pod{{\\rm mod}\\mkern6mu#1}"),Qr("\\mod","\\allowbreak\\mathchoice{\\mkern18mu}{\\mkern12mu}{\\mkern12mu}{\\mkern12mu}{\\rm mod}\\,\\,#1"),Qr("\\newline","\\\\\\relax"),Qr("\\TeX","\\textrm{\\html@mathml{T\\kern-.1667em\\raisebox{-.5ex}{E}\\kern-.125emX}{TeX}}");const eo=N(K["Main-Regular"]["T".charCodeAt(0)][1]-.7*K["Main-Regular"]["A".charCodeAt(0)][1]);Qr("\\LaTeX","\\textrm{\\html@mathml{L\\kern-.36em\\raisebox{"+eo+"}{\\scriptstyle A}\\kern-.15em\\TeX}{LaTeX}}"),Qr("\\KaTeX","\\textrm{\\html@mathml{K\\kern-.17em\\raisebox{"+eo+"}{\\scriptstyle A}\\kern-.15em\\TeX}{KaTeX}}"),Qr("\\hspace","\\@ifstar\\@hspacer\\@hspace"),Qr("\\@hspace","\\hskip #1\\relax"),Qr("\\@hspacer","\\rule{0pt}{0pt}\\hskip #1\\relax"),Qr("\\ordinarycolon",":"),Qr("\\vcentcolon","\\mathrel{\\mathop\\ordinarycolon}"),Qr("\\dblcolon",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-.9mu}\\vcentcolon}}{\\mathop{\\char"2237}}'),Qr("\\coloneqq",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}=}}{\\mathop{\\char"2254}}'),Qr("\\Coloneqq",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}=}}{\\mathop{\\char"2237\\char"3d}}'),Qr("\\coloneq",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}\\mathrel{-}}}{\\mathop{\\char"3a\\char"2212}}'),Qr("\\Coloneq",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}\\mathrel{-}}}{\\mathop{\\char"2237\\char"2212}}'),Qr("\\eqqcolon",'\\html@mathml{\\mathrel{=\\mathrel{\\mkern-1.2mu}\\vcentcolon}}{\\mathop{\\char"2255}}'),Qr("\\Eqqcolon",'\\html@mathml{\\mathrel{=\\mathrel{\\mkern-1.2mu}\\dblcolon}}{\\mathop{\\char"3d\\char"2237}}'),Qr("\\eqcolon",'\\html@mathml{\\mathrel{\\mathrel{-}\\mathrel{\\mkern-1.2mu}\\vcentcolon}}{\\mathop{\\char"2239}}'),Qr("\\Eqcolon",'\\html@mathml{\\mathrel{\\mathrel{-}\\mathrel{\\mkern-1.2mu}\\dblcolon}}{\\mathop{\\char"2212\\char"2237}}'),Qr("\\colonapprox",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}\\approx}}{\\mathop{\\char"3a\\char"2248}}'),Qr("\\Colonapprox",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}\\approx}}{\\mathop{\\char"2237\\char"2248}}'),Qr("\\colonsim",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}\\sim}}{\\mathop{\\char"3a\\char"223c}}'),Qr("\\Colonsim",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}\\sim}}{\\mathop{\\char"2237\\char"223c}}'),Qr("\u2237","\\dblcolon"),Qr("\u2239","\\eqcolon"),Qr("\u2254","\\coloneqq"),Qr("\u2255","\\eqqcolon"),Qr("\u2a74","\\Coloneqq"),Qr("\\ratio","\\vcentcolon"),Qr("\\coloncolon","\\dblcolon"),Qr("\\colonequals","\\coloneqq"),Qr("\\coloncolonequals","\\Coloneqq"),Qr("\\equalscolon","\\eqqcolon"),Qr("\\equalscoloncolon","\\Eqqcolon"),Qr("\\colonminus","\\coloneq"),Qr("\\coloncolonminus","\\Coloneq"),Qr("\\minuscolon","\\eqcolon"),Qr("\\minuscoloncolon","\\Eqcolon"),Qr("\\coloncolonapprox","\\Colonapprox"),Qr("\\coloncolonsim","\\Colonsim"),Qr("\\simcolon","\\mathrel{\\sim\\mathrel{\\mkern-1.2mu}\\vcentcolon}"),Qr("\\simcoloncolon","\\mathrel{\\sim\\mathrel{\\mkern-1.2mu}\\dblcolon}"),Qr("\\approxcolon","\\mathrel{\\approx\\mathrel{\\mkern-1.2mu}\\vcentcolon}"),Qr("\\approxcoloncolon","\\mathrel{\\approx\\mathrel{\\mkern-1.2mu}\\dblcolon}"),Qr("\\notni","\\html@mathml{\\not\\ni}{\\mathrel{\\char`\u220c}}"),Qr("\\limsup","\\DOTSB\\operatorname*{lim\\,sup}"),Qr("\\liminf","\\DOTSB\\operatorname*{lim\\,inf}"),Qr("\\injlim","\\DOTSB\\operatorname*{inj\\,lim}"),Qr("\\projlim","\\DOTSB\\operatorname*{proj\\,lim}"),Qr("\\varlimsup","\\DOTSB\\operatorname*{\\overline{lim}}"),Qr("\\varliminf","\\DOTSB\\operatorname*{\\underline{lim}}"),Qr("\\varinjlim","\\DOTSB\\operatorname*{\\underrightarrow{lim}}"),Qr("\\varprojlim","\\DOTSB\\operatorname*{\\underleftarrow{lim}}"),Qr("\\gvertneqq","\\html@mathml{\\@gvertneqq}{\u2269}"),Qr("\\lvertneqq","\\html@mathml{\\@lvertneqq}{\u2268}"),Qr("\\ngeqq","\\html@mathml{\\@ngeqq}{\u2271}"),Qr("\\ngeqslant","\\html@mathml{\\@ngeqslant}{\u2271}"),Qr("\\nleqq","\\html@mathml{\\@nleqq}{\u2270}"),Qr("\\nleqslant","\\html@mathml{\\@nleqslant}{\u2270}"),Qr("\\nshortmid","\\html@mathml{\\@nshortmid}{\u2224}"),Qr("\\nshortparallel","\\html@mathml{\\@nshortparallel}{\u2226}"),Qr("\\nsubseteqq","\\html@mathml{\\@nsubseteqq}{\u2288}"),Qr("\\nsupseteqq","\\html@mathml{\\@nsupseteqq}{\u2289}"),Qr("\\varsubsetneq","\\html@mathml{\\@varsubsetneq}{\u228a}"),Qr("\\varsubsetneqq","\\html@mathml{\\@varsubsetneqq}{\u2acb}"),Qr("\\varsupsetneq","\\html@mathml{\\@varsupsetneq}{\u228b}"),Qr("\\varsupsetneqq","\\html@mathml{\\@varsupsetneqq}{\u2acc}"),Qr("\\imath","\\html@mathml{\\@imath}{\u0131}"),Qr("\\jmath","\\html@mathml{\\@jmath}{\u0237}"),Qr("\\llbracket","\\html@mathml{\\mathopen{[\\mkern-3.2mu[}}{\\mathopen{\\char`\u27e6}}"),Qr("\\rrbracket","\\html@mathml{\\mathclose{]\\mkern-3.2mu]}}{\\mathclose{\\char`\u27e7}}"),Qr("\u27e6","\\llbracket"),Qr("\u27e7","\\rrbracket"),Qr("\\lBrace","\\html@mathml{\\mathopen{\\{\\mkern-3.2mu[}}{\\mathopen{\\char`\u2983}}"),Qr("\\rBrace","\\html@mathml{\\mathclose{]\\mkern-3.2mu\\}}}{\\mathclose{\\char`\u2984}}"),Qr("\u21a4","\\mapsfrom"),Qr("\\mapsfrom","\\html@mathml{\\mathrel{\\mathreflectbox{\\mapsto}}}{\\mathrel{\\char`\u21a4}}"),Qr("\u2983","\\lBrace"),Qr("\u2984","\\rBrace"),Qr("\\minuso","\\mathbin{\\html@mathml{{\\mathrlap{\\mathchoice{\\kern{0.145em}}{\\kern{0.145em}}{\\kern{0.1015em}}{\\kern{0.0725em}}\\circ}{-}}}{\\char`\u29b5}}"),Qr("\u29b5","\\minuso"),Qr("\\darr","\\downarrow"),Qr("\\dArr","\\Downarrow"),Qr("\\Darr","\\Downarrow"),Qr("\\lang","\\langle"),Qr("\\rang","\\rangle"),Qr("\\uarr","\\uparrow"),Qr("\\uArr","\\Uparrow"),Qr("\\Uarr","\\Uparrow"),Qr("\\N","\\mathbb{N}"),Qr("\\R","\\mathbb{R}"),Qr("\\Z","\\mathbb{Z}"),Qr("\\alef","\\aleph"),Qr("\\alefsym","\\aleph"),Qr("\\Alpha","\\mathrm{A}"),Qr("\\Beta","\\mathrm{B}"),Qr("\\bull","\\bullet"),Qr("\\Chi","\\mathrm{X}"),Qr("\\clubs","\\clubsuit"),Qr("\\cnums","\\mathbb{C}"),Qr("\\Complex","\\mathbb{C}"),Qr("\\Dagger","\\ddagger"),Qr("\\diamonds","\\diamondsuit"),Qr("\\empty","\\emptyset"),Qr("\\Epsilon","\\mathrm{E}"),Qr("\\Eta","\\mathrm{H}"),Qr("\\exist","\\exists"),Qr("\\harr","\\leftrightarrow"),Qr("\\hArr","\\Leftrightarrow"),Qr("\\Harr","\\Leftrightarrow"),Qr("\\hearts","\\heartsuit"),Qr("\\image","\\Im"),Qr("\\infin","\\infty"),Qr("\\Iota","\\mathrm{I}"),Qr("\\isin","\\in"),Qr("\\Kappa","\\mathrm{K}"),Qr("\\larr","\\leftarrow"),Qr("\\lArr","\\Leftarrow"),Qr("\\Larr","\\Leftarrow"),Qr("\\lrarr","\\leftrightarrow"),Qr("\\lrArr","\\Leftrightarrow"),Qr("\\Lrarr","\\Leftrightarrow"),Qr("\\Mu","\\mathrm{M}"),Qr("\\natnums","\\mathbb{N}"),Qr("\\Nu","\\mathrm{N}"),Qr("\\Omicron","\\mathrm{O}"),Qr("\\plusmn","\\pm"),Qr("\\rarr","\\rightarrow"),Qr("\\rArr","\\Rightarrow"),Qr("\\Rarr","\\Rightarrow"),Qr("\\real","\\Re"),Qr("\\reals","\\mathbb{R}"),Qr("\\Reals","\\mathbb{R}"),Qr("\\Rho","\\mathrm{P}"),Qr("\\sdot","\\cdot"),Qr("\\sect","\\S"),Qr("\\spades","\\spadesuit"),Qr("\\sub","\\subset"),Qr("\\sube","\\subseteq"),Qr("\\supe","\\supseteq"),Qr("\\Tau","\\mathrm{T}"),Qr("\\thetasym","\\vartheta"),Qr("\\weierp","\\wp"),Qr("\\Zeta","\\mathrm{Z}"),Qr("\\argmin","\\DOTSB\\operatorname*{arg\\,min}"),Qr("\\argmax","\\DOTSB\\operatorname*{arg\\,max}"),Qr("\\plim","\\DOTSB\\mathop{\\operatorname{plim}}\\limits"),Qr("\\bra","\\mathinner{\\langle{#1}|}"),Qr("\\ket","\\mathinner{|{#1}\\rangle}"),Qr("\\braket","\\mathinner{\\langle{#1}\\rangle}"),Qr("\\Bra","\\left\\langle#1\\right|"),Qr("\\Ket","\\left|#1\\right\\rangle");const to=e=>t=>{const r=t.consumeArg().tokens,n=t.consumeArg().tokens,o=t.consumeArg().tokens,s=t.consumeArg().tokens,i=t.macros.get("|"),a=t.macros.get("\\|");t.macros.beginGroup();const l=t=>r=>{e&&(r.macros.set("|",i),o.length&&r.macros.set("\\|",a));let s=t;if(!t&&o.length){"|"===r.future().text&&(r.popToken(),s=!0)}return{tokens:s?o:n,numArgs:0}};t.macros.set("|",l(!1)),o.length&&t.macros.set("\\|",l(!0));const c=t.consumeArg().tokens,h=t.expandTokens([...s,...c,...r]);return t.macros.endGroup(),{tokens:h.reverse(),numArgs:0}};Qr("\\bra@ket",to(!1)),Qr("\\bra@set",to(!0)),Qr("\\Braket","\\bra@ket{\\left\\langle}{\\,\\middle\\vert\\,}{\\,\\middle\\vert\\,}{\\right\\rangle}"),Qr("\\Set","\\bra@set{\\left\\{\\:}{\\;\\middle\\vert\\;}{\\;\\middle\\Vert\\;}{\\:\\right\\}}"),Qr("\\set","\\bra@set{\\{\\,}{\\mid}{}{\\,\\}}"),Qr("\\angln","{\\angl n}"),Qr("\\blue","\\textcolor{##6495ed}{#1}"),Qr("\\orange","\\textcolor{##ffa500}{#1}"),Qr("\\pink","\\textcolor{##ff00af}{#1}"),Qr("\\red","\\textcolor{##df0030}{#1}"),Qr("\\green","\\textcolor{##28ae7b}{#1}"),Qr("\\gray","\\textcolor{gray}{#1}"),Qr("\\purple","\\textcolor{##9d38bd}{#1}"),Qr("\\blueA","\\textcolor{##ccfaff}{#1}"),Qr("\\blueB","\\textcolor{##80f6ff}{#1}"),Qr("\\blueC","\\textcolor{##63d9ea}{#1}"),Qr("\\blueD","\\textcolor{##11accd}{#1}"),Qr("\\blueE","\\textcolor{##0c7f99}{#1}"),Qr("\\tealA","\\textcolor{##94fff5}{#1}"),Qr("\\tealB","\\textcolor{##26edd5}{#1}"),Qr("\\tealC","\\textcolor{##01d1c1}{#1}"),Qr("\\tealD","\\textcolor{##01a995}{#1}"),Qr("\\tealE","\\textcolor{##208170}{#1}"),Qr("\\greenA","\\textcolor{##b6ffb0}{#1}"),Qr("\\greenB","\\textcolor{##8af281}{#1}"),Qr("\\greenC","\\textcolor{##74cf70}{#1}"),Qr("\\greenD","\\textcolor{##1fab54}{#1}"),Qr("\\greenE","\\textcolor{##0d923f}{#1}"),Qr("\\goldA","\\textcolor{##ffd0a9}{#1}"),Qr("\\goldB","\\textcolor{##ffbb71}{#1}"),Qr("\\goldC","\\textcolor{##ff9c39}{#1}"),Qr("\\goldD","\\textcolor{##e07d10}{#1}"),Qr("\\goldE","\\textcolor{##a75a05}{#1}"),Qr("\\redA","\\textcolor{##fca9a9}{#1}"),Qr("\\redB","\\textcolor{##ff8482}{#1}"),Qr("\\redC","\\textcolor{##f9685d}{#1}"),Qr("\\redD","\\textcolor{##e84d39}{#1}"),Qr("\\redE","\\textcolor{##bc2612}{#1}"),Qr("\\maroonA","\\textcolor{##ffbde0}{#1}"),Qr("\\maroonB","\\textcolor{##ff92c6}{#1}"),Qr("\\maroonC","\\textcolor{##ed5fa6}{#1}"),Qr("\\maroonD","\\textcolor{##ca337c}{#1}"),Qr("\\maroonE","\\textcolor{##9e034e}{#1}"),Qr("\\purpleA","\\textcolor{##ddd7ff}{#1}"),Qr("\\purpleB","\\textcolor{##c6b9fc}{#1}"),Qr("\\purpleC","\\textcolor{##aa87ff}{#1}"),Qr("\\purpleD","\\textcolor{##7854ab}{#1}"),Qr("\\purpleE","\\textcolor{##543b78}{#1}"),Qr("\\mintA","\\textcolor{##f5f9e8}{#1}"),Qr("\\mintB","\\textcolor{##edf2df}{#1}"),Qr("\\mintC","\\textcolor{##e0e5cc}{#1}"),Qr("\\grayA","\\textcolor{##f6f7f7}{#1}"),Qr("\\grayB","\\textcolor{##f0f1f2}{#1}"),Qr("\\grayC","\\textcolor{##e3e5e6}{#1}"),Qr("\\grayD","\\textcolor{##d6d8da}{#1}"),Qr("\\grayE","\\textcolor{##babec2}{#1}"),Qr("\\grayF","\\textcolor{##888d93}{#1}"),Qr("\\grayG","\\textcolor{##626569}{#1}"),Qr("\\grayH","\\textcolor{##3b3e40}{#1}"),Qr("\\grayI","\\textcolor{##21242c}{#1}"),Qr("\\kaBlue","\\textcolor{##314453}{#1}"),Qr("\\kaGreen","\\textcolor{##71B307}{#1}");const ro={"^":!0,_:!0,"\\limits":!0,"\\nolimits":!0};class no{constructor(e,t,r){this.settings=void 0,this.expansionCount=void 0,this.lexer=void 0,this.macros=void 0,this.stack=void 0,this.mode=void 0,this.settings=t,this.expansionCount=0,this.feed(e),this.macros=new Wn(_n,t.macros),this.mode=r,this.stack=[]}feed(e){this.lexer=new Yn(e,this.settings)}switchMode(e){this.mode=e}beginGroup(){this.macros.beginGroup()}endGroup(){this.macros.endGroup()}endGroups(){this.macros.endGroups()}future(){return 0===this.stack.length&&this.pushToken(this.lexer.lex()),this.stack[this.stack.length-1]}popToken(){return this.future(),this.stack.pop()}pushToken(e){this.stack.push(e)}pushTokens(e){this.stack.push(...e)}scanArgument(e){let t,r,n;if(e){if(this.consumeSpaces(),"["!==this.future().text)return null;t=this.popToken();var o=this.consumeArg(["]"]);n=o.tokens,r=o.end}else{var s=this.consumeArg();n=s.tokens,t=s.start,r=s.end}return this.pushToken(new tn("EOF",r.loc)),this.pushTokens(n),new tn("",en.range(t,r))}consumeSpaces(){for(;;){if(" "!==this.future().text)break;this.stack.pop()}}consumeArg(e){const t=[],r=e&&e.length>0;r||this.consumeSpaces();const o=this.future();let s,i=0,a=0;do{if(s=this.popToken(),t.push(s),"{"===s.text)++i;else if("}"===s.text){if(--i,-1===i)throw new n("Extra }",s)}else if("EOF"===s.text)throw new n("Unexpected end of input in a macro argument, expected '"+(e&&r?e[a]:"}")+"'",s);if(e&&r)if((0===i||1===i&&"{"===e[a])&&s.text===e[a]){if(++a,a===e.length){t.splice(-a,a);break}}else a=0}while(0!==i||r);return"{"===o.text&&"}"===t[t.length-1].text&&(t.pop(),t.shift()),t.reverse(),{tokens:t,start:o,end:s}}consumeArgs(e,t){if(t){if(t.length!==e+1)throw new n("The length of delimiters doesn't match the number of args!");const r=t[0];for(let e=0;ethis.settings.maxExpand)throw new n("Too many expansions: infinite loop or need to increase maxExpand setting")}expandOnce(e){const t=this.popToken(),r=t.text,o=t.noexpand?null:this._getExpansion(r);if(null==o||e&&o.unexpandable){if(e&&null==o&&"\\"===r[0]&&!this.isDefined(r))throw new n("Undefined control sequence: "+r);return this.pushToken(t),!1}this.countExpansion(1);let s=o.tokens;const i=this.consumeArgs(o.numArgs,o.delimiters);if(o.numArgs){s=s.slice();for(let e=s.length-1;e>=0;--e){let t=s[e];if("#"===t.text){if(0===e)throw new n("Incomplete placeholder at end of macro body",t);if(t=s[--e],"#"===t.text)s.splice(e+1,1);else{if(!/^[1-9]$/.test(t.text))throw new n("Not a valid argument number",t);s.splice(e,2,...i[+t.text-1])}}}}return this.pushTokens(s),s.length}expandAfterFuture(){return this.expandOnce(),this.future()}expandNextToken(){for(;;)if(!1===this.expandOnce()){const e=this.stack.pop();return e.treatAsRelax&&(e.text="\\relax"),e}}expandMacro(e){return this.macros.has(e)?this.expandTokens([new tn(e)]):void 0}expandTokens(e){const t=[],r=this.stack.length;for(this.pushTokens(e);this.stack.length>r;)if(!1===this.expandOnce(!0)){const e=this.stack.pop();e.treatAsRelax&&(e.noexpand=!1,e.treatAsRelax=!1),t.push(e)}return this.countExpansion(t.length),t}expandMacroAsText(e){const t=this.expandMacro(e);return t?t.map(e=>e.text).join(""):t}_getExpansion(e){const t=this.macros.get(e);if(null==t)return t;if(1===e.length){const t=this.lexer.catcodes[e];if(null!=t&&13!==t)return}const r="function"==typeof t?t(this):t;if("string"==typeof r){let e=0;if(r.includes("#")){const t=r.replace(/##/g,"");for(;t.includes("#"+(e+1));)++e}const t=new Yn(r,this.settings),n=[];let o=t.lex();for(;"EOF"!==o.text;)n.push(o),o=t.lex();n.reverse();return{tokens:n,numArgs:e}}return r}isDefined(e){return this.macros.has(e)||Object.prototype.hasOwnProperty.call(Pn,e)||Object.prototype.hasOwnProperty.call(ne.math,e)||Object.prototype.hasOwnProperty.call(ne.text,e)||Object.prototype.hasOwnProperty.call(ro,e)}isExpandable(e){const t=this.macros.get(e);return null!=t?"string"==typeof t||"function"==typeof t||!t.unexpandable:Object.prototype.hasOwnProperty.call(Pn,e)&&!Pn[e].primitive}}const oo=/^[\u208a\u208b\u208c\u208d\u208e\u2080\u2081\u2082\u2083\u2084\u2085\u2086\u2087\u2088\u2089\u2090\u2091\u2095\u1d62\u2c7c\u2096\u2097\u2098\u2099\u2092\u209a\u1d63\u209b\u209c\u1d64\u1d65\u2093\u1d66\u1d67\u1d68\u1d69\u1d6a]/,so=Object.freeze({"\u208a":"+","\u208b":"-","\u208c":"=","\u208d":"(","\u208e":")","\u2080":"0","\u2081":"1","\u2082":"2","\u2083":"3","\u2084":"4","\u2085":"5","\u2086":"6","\u2087":"7","\u2088":"8","\u2089":"9","\u2090":"a","\u2091":"e","\u2095":"h","\u1d62":"i","\u2c7c":"j","\u2096":"k","\u2097":"l","\u2098":"m","\u2099":"n","\u2092":"o","\u209a":"p","\u1d63":"r","\u209b":"s","\u209c":"t","\u1d64":"u","\u1d65":"v","\u2093":"x","\u1d66":"\u03b2","\u1d67":"\u03b3","\u1d68":"\u03c1","\u1d69":"\u03d5","\u1d6a":"\u03c7","\u207a":"+","\u207b":"-","\u207c":"=","\u207d":"(","\u207e":")","\u2070":"0","\xb9":"1","\xb2":"2","\xb3":"3","\u2074":"4","\u2075":"5","\u2076":"6","\u2077":"7","\u2078":"8","\u2079":"9","\u1d2c":"A","\u1d2e":"B","\u1d30":"D","\u1d31":"E","\u1d33":"G","\u1d34":"H","\u1d35":"I","\u1d36":"J","\u1d37":"K","\u1d38":"L","\u1d39":"M","\u1d3a":"N","\u1d3c":"O","\u1d3e":"P","\u1d3f":"R","\u1d40":"T","\u1d41":"U","\u2c7d":"V","\u1d42":"W","\u1d43":"a","\u1d47":"b","\u1d9c":"c","\u1d48":"d","\u1d49":"e","\u1da0":"f","\u1d4d":"g","\u02b0":"h","\u2071":"i","\u02b2":"j","\u1d4f":"k","\u02e1":"l","\u1d50":"m","\u207f":"n","\u1d52":"o","\u1d56":"p","\u02b3":"r","\u02e2":"s","\u1d57":"t","\u1d58":"u","\u1d5b":"v","\u02b7":"w","\u02e3":"x","\u02b8":"y","\u1dbb":"z","\u1d5d":"\u03b2","\u1d5e":"\u03b3","\u1d5f":"\u03b4","\u1d60":"\u03d5","\u1d61":"\u03c7","\u1dbf":"\u03b8"}),io={"\u0301":{text:"\\'",math:"\\acute"},"\u0300":{text:"\\`",math:"\\grave"},"\u0308":{text:'\\"',math:"\\ddot"},"\u0303":{text:"\\~",math:"\\tilde"},"\u0304":{text:"\\=",math:"\\bar"},"\u0306":{text:"\\u",math:"\\breve"},"\u030c":{text:"\\v",math:"\\check"},"\u0302":{text:"\\^",math:"\\hat"},"\u0307":{text:"\\.",math:"\\dot"},"\u030a":{text:"\\r",math:"\\mathring"},"\u030b":{text:"\\H"},"\u0327":{text:"\\c"}},ao={"\xe1":"a\u0301","\xe0":"a\u0300","\xe4":"a\u0308","\u01df":"a\u0308\u0304","\xe3":"a\u0303","\u0101":"a\u0304","\u0103":"a\u0306","\u1eaf":"a\u0306\u0301","\u1eb1":"a\u0306\u0300","\u1eb5":"a\u0306\u0303","\u01ce":"a\u030c","\xe2":"a\u0302","\u1ea5":"a\u0302\u0301","\u1ea7":"a\u0302\u0300","\u1eab":"a\u0302\u0303","\u0227":"a\u0307","\u01e1":"a\u0307\u0304","\xe5":"a\u030a","\u01fb":"a\u030a\u0301","\u1e03":"b\u0307","\u0107":"c\u0301","\u1e09":"c\u0327\u0301","\u010d":"c\u030c","\u0109":"c\u0302","\u010b":"c\u0307","\xe7":"c\u0327","\u010f":"d\u030c","\u1e0b":"d\u0307","\u1e11":"d\u0327","\xe9":"e\u0301","\xe8":"e\u0300","\xeb":"e\u0308","\u1ebd":"e\u0303","\u0113":"e\u0304","\u1e17":"e\u0304\u0301","\u1e15":"e\u0304\u0300","\u0115":"e\u0306","\u1e1d":"e\u0327\u0306","\u011b":"e\u030c","\xea":"e\u0302","\u1ebf":"e\u0302\u0301","\u1ec1":"e\u0302\u0300","\u1ec5":"e\u0302\u0303","\u0117":"e\u0307","\u0229":"e\u0327","\u1e1f":"f\u0307","\u01f5":"g\u0301","\u1e21":"g\u0304","\u011f":"g\u0306","\u01e7":"g\u030c","\u011d":"g\u0302","\u0121":"g\u0307","\u0123":"g\u0327","\u1e27":"h\u0308","\u021f":"h\u030c","\u0125":"h\u0302","\u1e23":"h\u0307","\u1e29":"h\u0327","\xed":"i\u0301","\xec":"i\u0300","\xef":"i\u0308","\u1e2f":"i\u0308\u0301","\u0129":"i\u0303","\u012b":"i\u0304","\u012d":"i\u0306","\u01d0":"i\u030c","\xee":"i\u0302","\u01f0":"j\u030c","\u0135":"j\u0302","\u1e31":"k\u0301","\u01e9":"k\u030c","\u0137":"k\u0327","\u013a":"l\u0301","\u013e":"l\u030c","\u013c":"l\u0327","\u1e3f":"m\u0301","\u1e41":"m\u0307","\u0144":"n\u0301","\u01f9":"n\u0300","\xf1":"n\u0303","\u0148":"n\u030c","\u1e45":"n\u0307","\u0146":"n\u0327","\xf3":"o\u0301","\xf2":"o\u0300","\xf6":"o\u0308","\u022b":"o\u0308\u0304","\xf5":"o\u0303","\u1e4d":"o\u0303\u0301","\u1e4f":"o\u0303\u0308","\u022d":"o\u0303\u0304","\u014d":"o\u0304","\u1e53":"o\u0304\u0301","\u1e51":"o\u0304\u0300","\u014f":"o\u0306","\u01d2":"o\u030c","\xf4":"o\u0302","\u1ed1":"o\u0302\u0301","\u1ed3":"o\u0302\u0300","\u1ed7":"o\u0302\u0303","\u022f":"o\u0307","\u0231":"o\u0307\u0304","\u0151":"o\u030b","\u1e55":"p\u0301","\u1e57":"p\u0307","\u0155":"r\u0301","\u0159":"r\u030c","\u1e59":"r\u0307","\u0157":"r\u0327","\u015b":"s\u0301","\u1e65":"s\u0301\u0307","\u0161":"s\u030c","\u1e67":"s\u030c\u0307","\u015d":"s\u0302","\u1e61":"s\u0307","\u015f":"s\u0327","\u1e97":"t\u0308","\u0165":"t\u030c","\u1e6b":"t\u0307","\u0163":"t\u0327","\xfa":"u\u0301","\xf9":"u\u0300","\xfc":"u\u0308","\u01d8":"u\u0308\u0301","\u01dc":"u\u0308\u0300","\u01d6":"u\u0308\u0304","\u01da":"u\u0308\u030c","\u0169":"u\u0303","\u1e79":"u\u0303\u0301","\u016b":"u\u0304","\u1e7b":"u\u0304\u0308","\u016d":"u\u0306","\u01d4":"u\u030c","\xfb":"u\u0302","\u016f":"u\u030a","\u0171":"u\u030b","\u1e7d":"v\u0303","\u1e83":"w\u0301","\u1e81":"w\u0300","\u1e85":"w\u0308","\u0175":"w\u0302","\u1e87":"w\u0307","\u1e98":"w\u030a","\u1e8d":"x\u0308","\u1e8b":"x\u0307","\xfd":"y\u0301","\u1ef3":"y\u0300","\xff":"y\u0308","\u1ef9":"y\u0303","\u0233":"y\u0304","\u0177":"y\u0302","\u1e8f":"y\u0307","\u1e99":"y\u030a","\u017a":"z\u0301","\u017e":"z\u030c","\u1e91":"z\u0302","\u017c":"z\u0307","\xc1":"A\u0301","\xc0":"A\u0300","\xc4":"A\u0308","\u01de":"A\u0308\u0304","\xc3":"A\u0303","\u0100":"A\u0304","\u0102":"A\u0306","\u1eae":"A\u0306\u0301","\u1eb0":"A\u0306\u0300","\u1eb4":"A\u0306\u0303","\u01cd":"A\u030c","\xc2":"A\u0302","\u1ea4":"A\u0302\u0301","\u1ea6":"A\u0302\u0300","\u1eaa":"A\u0302\u0303","\u0226":"A\u0307","\u01e0":"A\u0307\u0304","\xc5":"A\u030a","\u01fa":"A\u030a\u0301","\u1e02":"B\u0307","\u0106":"C\u0301","\u1e08":"C\u0327\u0301","\u010c":"C\u030c","\u0108":"C\u0302","\u010a":"C\u0307","\xc7":"C\u0327","\u010e":"D\u030c","\u1e0a":"D\u0307","\u1e10":"D\u0327","\xc9":"E\u0301","\xc8":"E\u0300","\xcb":"E\u0308","\u1ebc":"E\u0303","\u0112":"E\u0304","\u1e16":"E\u0304\u0301","\u1e14":"E\u0304\u0300","\u0114":"E\u0306","\u1e1c":"E\u0327\u0306","\u011a":"E\u030c","\xca":"E\u0302","\u1ebe":"E\u0302\u0301","\u1ec0":"E\u0302\u0300","\u1ec4":"E\u0302\u0303","\u0116":"E\u0307","\u0228":"E\u0327","\u1e1e":"F\u0307","\u01f4":"G\u0301","\u1e20":"G\u0304","\u011e":"G\u0306","\u01e6":"G\u030c","\u011c":"G\u0302","\u0120":"G\u0307","\u0122":"G\u0327","\u1e26":"H\u0308","\u021e":"H\u030c","\u0124":"H\u0302","\u1e22":"H\u0307","\u1e28":"H\u0327","\xcd":"I\u0301","\xcc":"I\u0300","\xcf":"I\u0308","\u1e2e":"I\u0308\u0301","\u0128":"I\u0303","\u012a":"I\u0304","\u012c":"I\u0306","\u01cf":"I\u030c","\xce":"I\u0302","\u0130":"I\u0307","\u0134":"J\u0302","\u1e30":"K\u0301","\u01e8":"K\u030c","\u0136":"K\u0327","\u0139":"L\u0301","\u013d":"L\u030c","\u013b":"L\u0327","\u1e3e":"M\u0301","\u1e40":"M\u0307","\u0143":"N\u0301","\u01f8":"N\u0300","\xd1":"N\u0303","\u0147":"N\u030c","\u1e44":"N\u0307","\u0145":"N\u0327","\xd3":"O\u0301","\xd2":"O\u0300","\xd6":"O\u0308","\u022a":"O\u0308\u0304","\xd5":"O\u0303","\u1e4c":"O\u0303\u0301","\u1e4e":"O\u0303\u0308","\u022c":"O\u0303\u0304","\u014c":"O\u0304","\u1e52":"O\u0304\u0301","\u1e50":"O\u0304\u0300","\u014e":"O\u0306","\u01d1":"O\u030c","\xd4":"O\u0302","\u1ed0":"O\u0302\u0301","\u1ed2":"O\u0302\u0300","\u1ed6":"O\u0302\u0303","\u022e":"O\u0307","\u0230":"O\u0307\u0304","\u0150":"O\u030b","\u1e54":"P\u0301","\u1e56":"P\u0307","\u0154":"R\u0301","\u0158":"R\u030c","\u1e58":"R\u0307","\u0156":"R\u0327","\u015a":"S\u0301","\u1e64":"S\u0301\u0307","\u0160":"S\u030c","\u1e66":"S\u030c\u0307","\u015c":"S\u0302","\u1e60":"S\u0307","\u015e":"S\u0327","\u0164":"T\u030c","\u1e6a":"T\u0307","\u0162":"T\u0327","\xda":"U\u0301","\xd9":"U\u0300","\xdc":"U\u0308","\u01d7":"U\u0308\u0301","\u01db":"U\u0308\u0300","\u01d5":"U\u0308\u0304","\u01d9":"U\u0308\u030c","\u0168":"U\u0303","\u1e78":"U\u0303\u0301","\u016a":"U\u0304","\u1e7a":"U\u0304\u0308","\u016c":"U\u0306","\u01d3":"U\u030c","\xdb":"U\u0302","\u016e":"U\u030a","\u0170":"U\u030b","\u1e7c":"V\u0303","\u1e82":"W\u0301","\u1e80":"W\u0300","\u1e84":"W\u0308","\u0174":"W\u0302","\u1e86":"W\u0307","\u1e8c":"X\u0308","\u1e8a":"X\u0307","\xdd":"Y\u0301","\u1ef2":"Y\u0300","\u0178":"Y\u0308","\u1ef8":"Y\u0303","\u0232":"Y\u0304","\u0176":"Y\u0302","\u1e8e":"Y\u0307","\u0179":"Z\u0301","\u017d":"Z\u030c","\u1e90":"Z\u0302","\u017b":"Z\u0307","\u03ac":"\u03b1\u0301","\u1f70":"\u03b1\u0300","\u1fb1":"\u03b1\u0304","\u1fb0":"\u03b1\u0306","\u03ad":"\u03b5\u0301","\u1f72":"\u03b5\u0300","\u03ae":"\u03b7\u0301","\u1f74":"\u03b7\u0300","\u03af":"\u03b9\u0301","\u1f76":"\u03b9\u0300","\u03ca":"\u03b9\u0308","\u0390":"\u03b9\u0308\u0301","\u1fd2":"\u03b9\u0308\u0300","\u1fd1":"\u03b9\u0304","\u1fd0":"\u03b9\u0306","\u03cc":"\u03bf\u0301","\u1f78":"\u03bf\u0300","\u03cd":"\u03c5\u0301","\u1f7a":"\u03c5\u0300","\u03cb":"\u03c5\u0308","\u03b0":"\u03c5\u0308\u0301","\u1fe2":"\u03c5\u0308\u0300","\u1fe1":"\u03c5\u0304","\u1fe0":"\u03c5\u0306","\u03ce":"\u03c9\u0301","\u1f7c":"\u03c9\u0300","\u038e":"\u03a5\u0301","\u1fea":"\u03a5\u0300","\u03ab":"\u03a5\u0308","\u1fe9":"\u03a5\u0304","\u1fe8":"\u03a5\u0306","\u038f":"\u03a9\u0301","\u1ffa":"\u03a9\u0300"};class lo{constructor(e,t){this.mode=void 0,this.gullet=void 0,this.settings=void 0,this.leftrightDepth=void 0,this.nextToken=void 0,this.mode="math",this.gullet=new no(e,t,this.mode),this.settings=t,this.leftrightDepth=0,this.nextToken=null}expect(e,t){if(void 0===t&&(t=!0),this.fetch().text!==e)throw new n("Expected '"+e+"', got '"+this.fetch().text+"'",this.fetch());t&&this.consume()}consume(){this.nextToken=null}fetch(){return null==this.nextToken&&(this.nextToken=this.gullet.expandNextToken()),this.nextToken}switchMode(e){this.mode=e,this.gullet.switchMode(e)}parse(){this.settings.globalGroup||this.gullet.beginGroup(),this.settings.colorIsTextColor&&this.gullet.macros.set("\\color","\\textcolor");try{const e=this.parseExpression(!1);return this.expect("EOF"),this.settings.globalGroup||this.gullet.endGroup(),e}finally{this.gullet.endGroups()}}subparse(e){const t=this.nextToken;this.consume(),this.gullet.pushToken(new tn("}")),this.gullet.pushTokens(e);const r=this.parseExpression(!1);return this.expect("}"),this.nextToken=t,r}parseExpression(e,t){const r=[];for(;;){"math"===this.mode&&this.consumeSpaces();const n=this.fetch();if(lo.endOfExpression.has(n.text))break;if(t&&n.text===t)break;if(e&&Pn[n.text]&&Pn[n.text].infix)break;const o=this.parseAtom(t);if(!o)break;"internal"!==o.type&&r.push(o)}return"text"===this.mode&&this.formLigatures(r),this.handleInfixNodes(r)}handleInfixNodes(e){let t,r=-1;for(let o=0;o=128))return null;this.settings.strict&&(T(t.charCodeAt(0))?"math"===this.mode&&this.settings.reportNonstrict("unicodeTextInMathMode",'Unicode text character "'+t[0]+'" used in math mode',e):this.settings.reportNonstrict("unknownSymbol",'Unrecognized Unicode character "'+t[0]+'" ('+t.charCodeAt(0)+")",e)),o={type:"textord",mode:"text",loc:en.range(e),text:t}}var s;if(this.consume(),r)for(let t=0;t.katex>.katex-html>.katex-tag{position:static;margin-left:1em} diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/renderer.js b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/renderer.js new file mode 100644 index 000000000..b034628d7 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/renderer.js @@ -0,0 +1,30 @@ +"use strict"; +// Only native callAsyncJavaScript arguments enter this function. There are no +// native message handlers, transcript DOM, storage writes, links, or fetches. +window.mapleEquation = async function(source, display) { + const element = document.getElementById("equation"); + element.replaceChildren(); + if (typeof source !== "string" || new TextEncoder().encode(source).length > 16384) return null; + try { + const html = katex.renderToString(source, { + displayMode: display === true, output: "html", throwOnError: true, + trust: false, strict: "error", maxExpand: 1000, maxSize: 16, + macros: Object.create(null), globalGroup: false + }); + if (html.length > 262144) return null; + element.innerHTML = html; + if (element.querySelectorAll("*").length > 4096) { element.replaceChildren(); return null; } + // Font readiness is bounded by native timeout; packaged woff2 is the only + // permitted font source. No user-controlled DOM attribute is trusted. + // Flush style/layout first: otherwise ready may be an already-resolved + // promise before this formula has requested its packaged font faces. + element.getBoundingClientRect(); + await document.fonts.ready; + const box = element.getBoundingClientRect(); + const baseline = document.getElementById("baseline").getBoundingClientRect().top; + if (![box.width, box.height, baseline].every(Number.isFinite) || box.width < 1 || box.height < 1 || box.width > 4096 || box.height > 2048) { + element.replaceChildren(); return null; + } + return {width: Math.ceil(box.width), height: Math.ceil(box.height), descent: Math.max(0, box.bottom - baseline)}; + } catch (_) { element.replaceChildren(); return null; } +}; diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/Fonts/GeistMono-OFL.txt b/apps/maple-ios/Sources/MapleAppUI/Resources/Fonts/GeistMono-OFL.txt new file mode 100644 index 000000000..04e95fc55 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Resources/Fonts/GeistMono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/Fonts/GeistMono-Regular.ttf b/apps/maple-ios/Sources/MapleAppUI/Resources/Fonts/GeistMono-Regular.ttf new file mode 100644 index 000000000..50c9d5a66 Binary files /dev/null and b/apps/maple-ios/Sources/MapleAppUI/Resources/Fonts/GeistMono-Regular.ttf differ diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/Library.xcstrings b/apps/maple-ios/Sources/MapleAppUI/Resources/Library.xcstrings new file mode 100644 index 000000000..9d7fa880a --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Resources/Library.xcstrings @@ -0,0 +1,1291 @@ +{ + "sourceLanguage": "en", + "strings": { + "Another account update is in progress. Wait for it to finish.": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Another account update is in progress. Wait for it to finish." + } + } + } + }, + "Cancel": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + } + } + }, + "Chat actions": { + "comment": "Accessibility label for the trailing ellipsis on a chat row. Opens select, pin, move, rename and delete actions for that chat.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chat actions" + } + } + } + }, + "Chat name": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chat name" + } + } + } + }, + "Chats": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chats" + } + } + } + }, + "Check the name or selection and try again.": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check the name or selection and try again." + } + } + } + }, + "Clear search": { + "comment": "Accessibility label for clearing the main menu title search field.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear search" + } + } + } + }, + "Close menu": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close menu" + } + } + } + }, + "Close settings": { + "comment": "Accessibility label for the close button on full-screen settings in regular-width layouts.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close settings" + } + } + } + }, + "Collapsed": { + "comment": "Accessibility state of a collapsed project folder.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Collapsed" + } + } + } + }, + "Create": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create" + } + } + } + }, + "Delete": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete" + } + } + } + }, + "Delete all chats": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete all chats" + } + } + } + }, + "Delete chat?": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete chat?" + } + } + } + }, + "Delete project": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete project" + } + } + } + }, + "Delete selected chats": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete selected chats" + } + } + } + }, + "Delete selected chats?": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete selected chats?" + } + } + }, + "generatesSymbol": false + }, + "Deleted chats cannot be restored.": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Deleted chats cannot be restored." + } + } + } + }, + "Discard changes": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Discard changes" + } + } + } + }, + "Discard unsaved changes?": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Discard unsaved changes?" + } + } + } + }, + "Done": { + "comment": "Visible navigation-bar button that exits chat selection and clears the selection, without changing any chats.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Done" + } + } + } + }, + "Done selecting": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Done selecting" + } + } + } + }, + "Expanded": { + "comment": "Accessibility state of an expanded project folder.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Expanded" + } + } + } + }, + "Instructions": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Instructions" + } + } + } + }, + "Instructions apply to future messages. Save to apply your changes.": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Instructions apply to future messages. Save to apply your changes." + } + } + } + }, + "Load more": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Load more" + } + } + } + }, + "Manage project": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Manage project" + } + } + } + }, + "Maple could not confirm that all affected requests stopped. Nothing was deleted. Try again after the requests settle.": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Maple could not confirm that all affected requests stopped. Nothing was deleted. Try again after the requests settle." + } + } + } + }, + "Maple could not load or update this information. Check your connection and try again.": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Maple could not load or update this information. Check your connection and try again." + } + } + } + }, + "Maple could not verify the service response. Refresh before trying again.": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Maple could not verify the service response. Refresh before trying again." + } + } + } + }, + "Maple menu": { + "comment": "Accessibility label for the Maple wordmark button opening Settings and chat management actions.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Maple menu" + } + } + } + }, + "Maximum of 10 projects reached": { + "comment": "Accessibility hint for the disabled New project control when the account has reached Research's ten-project maximum.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Maximum of 10 projects reached" + } + } + } + }, + "Move selected chats": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move selected chats" + } + } + } + }, + "Move to project": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move to project" + } + } + } + }, + "New chat": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New chat" + } + } + } + }, + "New chat in project": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New chat in project" + } + } + } + }, + "New project": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New project" + } + } + } + }, + "No chats found matching \"%@\"": { + "comment": "Library empty search result. The placeholder is the complete trimmed search query. Preserve its quotes and do not translate the query.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No chats found matching \"%@\"" + } + } + } + }, + "No chats yet": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No chats yet" + } + } + } + }, + "No project": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No project" + } + } + } + }, + "No recent chats.": { + "comment": "Empty state under the Recent section of the main menu.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No recent chats." + } + } + } + }, + "Pin": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pin" + } + } + } + }, + "Pinned": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pinned" + } + } + } + }, + "Project": { + "comment": "Project destination row in the chat options sheet; the current project name is displayed separately.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Project" + } + } + } + }, + "Project actions": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Project actions" + } + } + } + }, + "Project instructions": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Project instructions" + } + } + } + }, + "Project name": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Project name" + } + } + } + }, + "Projects": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Projects" + } + } + } + }, + "Recent": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recent" + } + } + } + }, + "Rename": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rename" + } + } + } + }, + "Rename project": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rename project" + } + } + } + }, + "Save": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save" + } + } + } + }, + "Search chats": { + "comment": "Floating Library search field. Searches titles already loaded in this account; keep concise.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search chats" + } + } + } + }, + "Search loaded titles": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search loaded titles" + } + } + } + }, + "Select": { + "comment": "Chat-row menu action that enters selection mode with this chat selected.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Select" + } + } + } + }, + "Select chats": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Select chats" + } + } + } + }, + "Settings": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Settings" + } + } + } + }, + "Some selected chats could not be updated. The remaining selection still needs attention.": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Some selected chats could not be updated. The remaining selection still needs attention." + } + } + } + }, + "The server deletion finished, but local cleanup needs another attempt before more changes can be made.": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The server deletion finished, but local cleanup needs another attempt before more changes can be made." + } + } + } + }, + "This deletes every chat in this account, including chats in projects. Projects remain.": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This deletes every chat in this account, including chats in projects. Projects remain." + } + } + } + }, + "This permanently deletes the project and every chat in it.": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This permanently deletes the project and every chat in it." + } + } + } + }, + "Try a different search term": { + "comment": "Library empty search result suggestion below the searched query.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Try a different search term" + } + } + } + }, + "Try again": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Try again" + } + } + } + }, + "Type DELETE to confirm": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. DELETE is the exact confirmation token and must remain untranslated.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Type DELETE to confirm" + } + } + } + }, + "Unpin": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unpin" + } + } + } + }, + "Untitled chat": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Untitled chat" + } + } + } + }, + "Your sign-in changed. Reopen this screen to continue.": { + "comment": "Library, chat history, project management, or explicit-save instructions interface. Preserve the meaning of this action or status; allow flexible text length.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your sign-in changed. Reopen this screen to continue." + } + } + } + }, + "Loading...": { + "comment": "Read-only account summary. Usage appears in Settings; team attention stays in the library. No pricing or purchase action.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading..." + } + } + } + }, + "Resets Monthly": { + "comment": "Read-only account summary. Usage appears in Settings; team attention stays in the library. No pricing or purchase action.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Resets Monthly" + } + } + } + }, + "Resets in less than 1 hour": { + "comment": "Read-only account summary. Usage appears in Settings; team attention stays in the library. No pricing or purchase action.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Resets in less than 1 hour" + } + } + } + }, + "Resets Tomorrow": { + "comment": "Read-only account summary. Usage appears in Settings; team attention stays in the library. No pricing or purchase action.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Resets Tomorrow" + } + } + } + }, + "Team usage paused": { + "comment": "Read-only account summary. Usage appears in Settings; team attention stays in the library. No pricing or purchase action.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Team usage paused" + } + } + } + }, + "Team setup required": { + "comment": "Read-only account summary. Usage appears in Settings; team attention stays in the library. No pricing or purchase action.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Team setup required" + } + } + } + }, + "library.usage.product-plan": { + "comment": "Read-only account summary. Usage appears in Settings; team attention stays in the library. No pricing or purchase action.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ Plan" + } + } + } + }, + "library.usage.percent-used": { + "comment": "Read-only account summary. Usage appears in Settings; team attention stays in the library. No pricing or purchase action.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@%% used" + } + } + } + }, + "library.usage.credits": { + "comment": "Read-only account summary. Usage appears in Settings; team attention stays in the library. No pricing or purchase action.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "+%@ credits" + } + } + } + }, + "library.usage.reset-date": { + "comment": "Read-only account summary. Usage appears in Settings; team attention stays in the library. No pricing or purchase action.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Resets %@" + } + } + } + }, + "library.usage.reset-hours": { + "comment": "Read-only account summary. Usage appears in Settings; team attention stays in the library. No pricing or purchase action.", + "extractionState": "manual", + "localizations": { + "en": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Resets in %lld hour" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Resets in %lld hours" + } + } + } + } + } + } + }, + "library.usage.reset-days": { + "comment": "Read-only account summary. Usage appears in Settings; team attention stays in the library. No pricing or purchase action.", + "extractionState": "manual", + "localizations": { + "en": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Resets in %lld day" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Resets in %lld days" + } + } + } + } + } + } + }, + "View project": { + "comment": "Research library parity copy. User titles and project names are interpolation values, never localization keys.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "View project" + } + } + } + }, + "Rename Chat": { + "comment": "Research library parity copy. User titles and project names are interpolation values, never localization keys.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rename Chat" + } + } + } + }, + "Chat Title": { + "comment": "Research library parity copy. User titles and project names are interpolation values, never localization keys.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chat Title" + } + } + } + }, + "Chat title cannot be empty.": { + "comment": "Research library parity copy. User titles and project names are interpolation values, never localization keys.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chat title cannot be empty." + } + } + } + }, + "Enter a new name for this chat conversation.": { + "comment": "Research library parity copy. User titles and project names are interpolation values, never localization keys.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a new name for this chat conversation." + } + } + } + }, + "Please enter a different title.": { + "comment": "Research library parity copy. User titles and project names are interpolation values, never localization keys.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Please enter a different title." + } + } + } + }, + "Please enter a different project name.": { + "comment": "Research library parity copy. User titles and project names are interpolation values, never localization keys.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Please enter a different project name." + } + } + } + }, + "Custom Instructions": { + "comment": "Research library parity copy. User titles and project names are interpolation values, never localization keys.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom Instructions" + } + } + } + }, + "Set shared custom instructions that apply to chats in this project.": { + "comment": "Research library parity copy. User titles and project names are interpolation values, never localization keys.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Set shared custom instructions that apply to chats in this project." + } + } + } + }, + "Loading instructions": { + "comment": "Research library parity copy. User titles and project names are interpolation values, never localization keys.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading instructions" + } + } + } + }, + "e.g. Use concise bullet points. Focus on practical examples.": { + "comment": "Research library parity copy. User titles and project names are interpolation values, never localization keys.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "e.g. Use concise bullet points. Focus on practical examples." + } + } + } + }, + "Leave this empty to clear the project's custom instructions.": { + "comment": "Research library parity copy. User titles and project names are interpolation values, never localization keys.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Leave this empty to clear the project's custom instructions." + } + } + } + }, + "Are you sure you want to delete this chat?": { + "comment": "Research library parity copy. User titles and project names are interpolation values, never localization keys.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Are you sure you want to delete this chat?" + } + } + } + }, + "Delete this project?": { + "comment": "Research library parity copy. User titles and project names are interpolation values, never localization keys.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete this project?" + } + } + } + }, + "%@, running": { + "comment": "Research library parity copy. User titles and project names are interpolation values, never localization keys.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@, running" + } + } + } + }, + "%@, completed, unread": { + "comment": "Research library parity copy. User titles and project names are interpolation values, never localization keys.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@, completed, unread" + } + } + } + }, + "Shared instructions for chats in %@": { + "comment": "Research library parity copy. User titles and project names are interpolation values, never localization keys.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shared instructions for chats in %@" + } + } + } + }, + "This will permanently delete \"%@\". This action cannot be undone.": { + "comment": "Research library parity copy. User titles and project names are interpolation values, never localization keys.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This will permanently delete \"%@\". This action cannot be undone." + } + } + } + }, + "This will permanently delete \"%@\" and any chats in it. Move chats out first if you want to keep them. This action cannot be undone.": { + "comment": "Research library parity copy. User titles and project names are interpolation values, never localization keys.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This will permanently delete \"%@\" and any chats in it. Move chats out first if you want to keep them. This action cannot be undone." + } + } + } + }, + "Delete %lld chats?": { + "comment": "Research library parity copy. User titles and project names are interpolation values, never localization keys.", + "extractionState": "manual", + "localizations": { + "en": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Delete %lld chat?" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Delete %lld chats?" + } + } + } + } + } + } + }, + "This will permanently delete %lld selected chats. This action cannot be undone.": { + "comment": "Research library parity copy. User titles and project names are interpolation values, never localization keys.", + "extractionState": "manual", + "localizations": { + "en": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "This will permanently delete %lld selected chat. This action cannot be undone." + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "This will permanently delete %lld selected chats. This action cannot be undone." + } + } + } + } + } + } + }, + "New chat in %@": { + "comment": "Project detail action starting a new chat. The argument is the user-provided project name; preserve it as an interpolation value.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New chat in %@" + } + } + } + }, + "No chats in this project yet.": { + "comment": "Research project detail empty state after its first chat page loads successfully.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No chats in this project yet." + } + } + } + }, + "Settings, %@": { + "comment": "Accessibility label for Settings with a read-only team attention status. Keep the visible action title as Settings; the argument is localized status text.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Settings, %@" + } + } + } + } + }, + "version": "1.0" +} diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/Localizable.xcstrings b/apps/maple-ios/Sources/MapleAppUI/Resources/Localizable.xcstrings new file mode 100644 index 000000000..28a697820 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Resources/Localizable.xcstrings @@ -0,0 +1,2651 @@ +{ + "sourceLanguage": "en", + "strings": { + "Collapsed": { + "comment": "VoiceOver expansion state for a transcript tool details button.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Collapsed" + } + } + } + }, + "Expanded": { + "comment": "VoiceOver expansion state for a transcript tool details button.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Expanded" + } + } + } + }, + "Account access is unavailable. Your draft is preserved.": { + "comment": "Account-access status shown beside the composer; preserve the distinction between unavailable details and a confirmed restriction.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Account access is unavailable. Your draft is preserved." + } + } + } + }, + "Account cleanup must finish before you can sign in again.": { + "comment": "Account startup, restoration, or protected-storage cleanup interface. Maple is the application name and stays unchanged.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Account cleanup must finish before you can sign in again." + } + } + } + }, + "Account details are still loading. Try again when loading finishes.": { + "comment": "Read-only explanation or VoiceOver state for an unavailable image, dictation, read-aloud, or web-search control. Distinguish unknown account details from a confirmed plan, quota, or capability restriction; this screen does not offer purchases.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Account details are still loading. Try again when loading finishes." + } + } + } + }, + "Account details are unavailable. Refresh account status and try again.": { + "comment": "Read-only explanation or VoiceOver state for an unavailable image, dictation, read-aloud, or web-search control. Distinguish unknown account details from a confirmed plan, quota, or capability restriction; this screen does not offer purchases.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Account details are unavailable. Refresh account status and try again." + } + } + } + }, + "Already have an account? Sign in": { + "comment": "Email sign-in, signup, or existing guest-account sign-in form. Preserve existing-account versus new-account distinctions. Maple is the application name.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Already have an account? Sign in" + } + } + } + }, + "An account already uses this email address.": { + "comment": "Email sign-in, signup, or existing guest-account sign-in form. Preserve existing-account versus new-account distinctions. Maple is the application name.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "An account already uses this email address." + } + } + } + }, + "Attach image": { + "comment": "Image attachment selection, eligibility, or import feedback. These messages refer to the current draft.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Attach image" + } + } + } + }, + "Attached image": { + "comment": "Attached-image preview, fullscreen zoom, dismissal, or VoiceOver action.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Attached image" + } + } + } + }, + "Available after this reply finishes.": { + "comment": "Read-only explanation or VoiceOver state for an unavailable image, dictation, read-aloud, or web-search control. Distinguish unknown account details from a confirmed plan, quota, or capability restriction; this screen does not offer purchases.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Available after this reply finishes." + } + } + } + }, + "Back": { + "comment": "Confirmation or navigation in the user-opened external browser. This browser is separate from chat content.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Back" + } + } + } + }, + "Cancel": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + } + } + }, + "Check response": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check response" + } + } + } + }, + "Check the account details and try again.": { + "comment": "Email sign-in, signup, or existing guest-account sign-in form. Preserve existing-account versus new-account distinctions. Maple is the application name.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check the account details and try again." + } + } + } + }, + "Check the image attachments and try again.": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check the image attachments and try again." + } + } + } + }, + "Checking account access…": { + "comment": "Account-access status shown beside the composer; preserve the distinction between unavailable details and a confirmed restriction.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Checking account access…" + } + } + } + }, + "Checking response…": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Checking response…" + } + } + } + }, + "Choose a model that supports images.": { + "comment": "Read-only explanation or VoiceOver state for an unavailable image, dictation, read-aloud, or web-search control. Distinguish unknown account details from a confirmed plan, quota, or capability restriction; this screen does not offer purchases.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a model that supports images." + } + } + } + }, + "Close image": { + "comment": "Attached-image preview, fullscreen zoom, dismissal, or VoiceOver action.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close image" + } + } + } + }, + "Completed": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Completed" + } + } + } + }, + "Configuration unavailable": { + "comment": "Account startup, restoration, or protected-storage cleanup interface. Maple is the application name and stays unchanged.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Configuration unavailable" + } + } + } + }, + "Copy code": { + "comment": "VoiceOver label for copying one code block from a message into the local clipboard.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy code" + } + } + } + }, + "Copy message": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy message" + } + } + } + }, + "Couldn’t load the conversation. Try again when connected.": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn’t load the conversation. Try again when connected." + } + } + } + }, + "Create account": { + "comment": "Email sign-in, signup, or existing guest-account sign-in form. Preserve existing-account versus new-account distinctions. Maple is the application name.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create account" + } + } + } + }, + "Create an account": { + "comment": "Email sign-in, signup, or existing guest-account sign-in form. Preserve existing-account versus new-account distinctions. Maple is the application name.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create an account" + } + } + } + }, + "Create your account": { + "comment": "Email sign-in, signup, or existing guest-account sign-in form. Preserve existing-account versus new-account distinctions. Maple is the application name.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create your account" + } + } + } + }, + "Delete": { + "comment": "Queued-message review, pause, editing, or deletion control.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete" + } + } + } + }, + "Delete all chat history?": { + "comment": "Account settings startup or permanent chat-history deletion confirmation.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete all chat history?" + } + } + } + }, + "Delete all chats": { + "comment": "Account settings startup or permanent chat-history deletion confirmation.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete all chats" + } + } + } + }, + "Dictate": { + "comment": "Read-only explanation or VoiceOver state for an unavailable image, dictation, read-aloud, or web-search control. Distinguish unknown account details from a confirmed plan, quota, or capability restriction; this screen does not offer purchases.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dictate" + } + } + } + }, + "Dictation": { + "comment": "Read-only explanation or VoiceOver state for an unavailable image, dictation, read-aloud, or web-search control. Distinguish unknown account details from a confirmed plan, quota, or capability restriction; this screen does not offer purchases.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dictation" + } + } + } + }, + "Dictation is currently unavailable. Try again later.": { + "comment": "Read-only explanation or VoiceOver state for an unavailable image, dictation, read-aloud, or web-search control. Distinguish unknown account details from a confirmed plan, quota, or capability restriction; this screen does not offer purchases.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dictation is currently unavailable. Try again later." + } + } + } + }, + "Dictation requires an eligible paid plan.": { + "comment": "Read-only explanation or VoiceOver state for an unavailable image, dictation, read-aloud, or web-search control. Distinguish unknown account details from a confirmed plan, quota, or capability restriction; this screen does not offer purchases.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dictation requires an eligible paid plan." + } + } + } + }, + "Done": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Done" + } + } + } + }, + "Edit": { + "comment": "Queued-message review, pause, editing, or deletion control.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit" + } + } + } + }, + "Editing queued message": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Editing queued message" + } + } + } + }, + "Email": { + "comment": "Email sign-in, signup, or existing guest-account sign-in form. Preserve existing-account versus new-account distinctions. Maple is the application name.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Email" + } + } + } + }, + "Encrypted and private at every step": { + "comment": "Research privacy assurance shown only below the empty new-chat composer beside a lock glyph.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Encrypted and private at every step" + } + } + } + }, + "Equations": { + "comment": "Accessibility custom content containing the original formula text.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Equations" + } + } + } + }, + "Failed": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed" + } + } + } + }, + "Files": { + "comment": "Image attachment selection, eligibility, or import feedback. These messages refer to the current draft.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Files" + } + } + } + }, + "Forgot password?": { + "comment": "Email sign-in, signup, or existing guest-account sign-in form. Preserve existing-account versus new-account distinctions. Maple is the application name.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Forgot password?" + } + } + } + }, + "Forward": { + "comment": "Confirmation or navigation in the user-opened external browser. This browser is separate from chat content.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Forward" + } + } + } + }, + "Guest account ID": { + "comment": "Email sign-in, signup, or existing guest-account sign-in form. Preserve existing-account versus new-account distinctions. Maple is the application name.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Guest account ID" + } + } + } + }, + "Image": { + "comment": "Attached-image preview, fullscreen zoom, dismissal, or VoiceOver action.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Image" + } + } + } + }, + "Image attachments require an eligible paid plan.": { + "comment": "Read-only explanation or VoiceOver state for an unavailable image, dictation, read-aloud, or web-search control. Distinguish unknown account details from a confirmed plan, quota, or capability restriction; this screen does not offer purchases.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Image attachments require an eligible paid plan." + } + } + } + }, + "Image unavailable": { + "comment": "Attached-image preview, fullscreen zoom, dismissal, or VoiceOver action.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Image unavailable" + } + } + } + }, + "Images": { + "comment": "Read-only explanation or VoiceOver state for an unavailable image, dictation, read-aloud, or web-search control. Distinguish unknown account details from a confirmed plan, quota, or capability restriction; this screen does not offer purchases.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Images" + } + } + } + }, + "Incomplete": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Incomplete" + } + } + } + }, + "Learn why this feature is unavailable.": { + "comment": "Read-only explanation or VoiceOver state for an unavailable image, dictation, read-aloud, or web-search control. Distinguish unknown account details from a confirmed plan, quota, or capability restriction; this screen does not offer purchases.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Learn why this feature is unavailable." + } + } + } + }, + "Loading account…": { + "comment": "Account settings startup or permanent chat-history deletion confirmation.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading account…" + } + } + } + }, + "Maple": { + "comment": "Maple application brand on the sign-in screen. Do not translate the brand name.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Maple" + } + } + } + }, + "Maple couldn’t open protected account storage.": { + "comment": "Account startup, restoration, or protected-storage cleanup interface. Maple is the application name and stays unchanged.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Maple couldn’t open protected account storage." + } + } + } + }, + "Menu": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Menu" + } + } + } + }, + "Message": { + "comment": "Accessibility container for one chat message and its controls.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Message" + } + } + } + }, + "Model": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Model" + } + } + } + }, + "Name (optional)": { + "comment": "Email sign-in, signup, or existing guest-account sign-in form. Preserve existing-account versus new-account distinctions. Maple is the application name.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Name (optional)" + } + } + } + }, + "New chat": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New chat" + } + } + } + }, + "No project": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No project" + } + } + } + }, + "OK": { + "comment": "Image attachment selection, eligibility, or import feedback. These messages refer to the current draft.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + } + } + }, + "Offline — your draft stays here": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Offline — your draft stays here" + } + } + } + }, + "Open external link?": { + "comment": "Confirmation or navigation in the user-opened external browser. This browser is separate from chat content.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open external link?" + } + } + } + }, + "Open in default browser": { + "comment": "Confirmation or navigation in the user-opened external browser. This browser is separate from chat content.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open in default browser" + } + } + } + }, + "Open link": { + "comment": "Confirmation or navigation in the user-opened external browser. This browser is separate from chat content.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open link" + } + } + } + }, + "Opening Maple…": { + "comment": "Account startup, restoration, or protected-storage cleanup interface. Maple is the application name and stays unchanged.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Opening Maple…" + } + } + } + }, + "Opening chat…": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Opening chat…" + } + } + } + }, + "Opening menu…": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Opening menu…" + } + } + } + }, + "Password": { + "comment": "Email sign-in, signup, or existing guest-account sign-in form. Preserve existing-account versus new-account distinctions. Maple is the application name.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Password" + } + } + } + }, + "Paste an image from the clipboard.": { + "comment": "Image attachment selection, eligibility, or import feedback. These messages refer to the current draft.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Paste an image from the clipboard." + } + } + } + }, + "Paste image": { + "comment": "Image attachment selection, eligibility, or import feedback. These messages refer to the current draft.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Paste image" + } + } + } + }, + "Pause queue": { + "comment": "Queued-message review, pause, editing, or deletion control.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pause queue" + } + } + } + }, + "Photo library": { + "comment": "Image attachment selection, eligibility, or import feedback. These messages refer to the current draft.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Photo library" + } + } + } + }, + "Preparing images…": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preparing images…" + } + } + } + }, + "Preparing…": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preparing…" + } + } + } + }, + "Private conversation": { + "comment": "VoiceOver label for the subtle lock beside the chat title. Describes a private conversation without adding visible explanatory text.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Private conversation" + } + } + } + }, + "Project": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Project" + } + } + } + }, + "Protected account storage is unavailable. Try again after unlocking.": { + "comment": "Email sign-in, signup, or existing guest-account sign-in form. Preserve existing-account versus new-account distinctions. Maple is the application name.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Protected account storage is unavailable. Try again after unlocking." + } + } + } + }, + "Queue message": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Queue message" + } + } + } + }, + "Queued messages": { + "comment": "Queued-message review, pause, editing, or deletion control.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Queued messages" + } + } + } + }, + "Queued messages are paused. Resume when you’re ready.": { + "comment": "Queued-message review, pause, editing, or deletion control.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Queued messages are paused. Resume when you’re ready." + } + } + } + }, + "Read aloud": { + "comment": "Read-only explanation or VoiceOver state for an unavailable image, dictation, read-aloud, or web-search control. Distinguish unknown account details from a confirmed plan, quota, or capability restriction; this screen does not offer purchases.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Read aloud" + } + } + } + }, + "Read aloud is currently unavailable. Try again later.": { + "comment": "Read-only explanation or VoiceOver state for an unavailable image, dictation, read-aloud, or web-search control. Distinguish unknown account details from a confirmed plan, quota, or capability restriction; this screen does not offer purchases.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Read aloud is currently unavailable. Try again later." + } + } + } + }, + "Read aloud requires an eligible paid plan.": { + "comment": "Read-only explanation or VoiceOver state for an unavailable image, dictation, read-aloud, or web-search control. Distinguish unknown account details from a confirmed plan, quota, or capability restriction; this screen does not offer purchases.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Read aloud requires an eligible paid plan." + } + } + } + }, + "Read reply aloud": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Read reply aloud" + } + } + } + }, + "Reload": { + "comment": "Confirmation or navigation in the user-opened external browser. This browser is separate from chat content.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reload" + } + } + } + }, + "Remove image": { + "comment": "Attached-image preview, fullscreen zoom, dismissal, or VoiceOver action.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove image" + } + } + } + }, + "Research anything...": { + "comment": "Empty-chat headline matching Maple Research. Research is a verb here; the trailing three dots are intentional. Displayed in the Array brand font.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Research anything..." + } + } + } + }, + "Responding…": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Responding…" + } + } + } + }, + "Restore unsent message": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restore unsent message" + } + } + } + }, + "Resume queue": { + "comment": "Queued-message review, pause, editing, or deletion control.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Resume queue" + } + } + } + }, + "Retry": { + "comment": "Account-access status shown beside the composer; preserve the distinction between unavailable details and a confirmed restriction.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Retry" + } + } + } + }, + "Review unsent work": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Review unsent work" + } + } + } + }, + "Save changes": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save changes" + } + } + } + }, + "Secure storage unavailable": { + "comment": "Account startup, restoration, or protected-storage cleanup interface. Maple is the application name and stays unchanged.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Secure storage unavailable" + } + } + } + }, + "Send": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Send" + } + } + } + }, + "Sending…": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sending…" + } + } + } + }, + "Sign in": { + "comment": "Email sign-in, signup, or existing guest-account sign-in form. Preserve existing-account versus new-account distinctions. Maple is the application name.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in" + } + } + } + }, + "Sign in with email": { + "comment": "Email sign-in, signup, or existing guest-account sign-in form. Preserve existing-account versus new-account distinctions. Maple is the application name.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in with email" + } + } + } + }, + "Sign out": { + "comment": "Account startup, restoration, or protected-storage cleanup interface. Maple is the application name and stays unchanged.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign out" + } + } + } + }, + "Signing out…": { + "comment": "Account startup, restoration, or protected-storage cleanup interface. Maple is the application name and stays unchanged.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Signing out…" + } + } + } + }, + "Stop": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stop" + } + } + } + }, + "Stop hasn’t been confirmed. Check the response to try again.": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stop hasn’t been confirmed. Check the response to try again." + } + } + } + }, + "Stopping…": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stopping…" + } + } + } + }, + "Take photo": { + "comment": "Image attachment selection, eligibility, or import feedback. These messages refer to the current draft.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Take photo" + } + } + } + }, + "The account details were not accepted. Try again.": { + "comment": "Email sign-in, signup, or existing guest-account sign-in form. Preserve existing-account versus new-account distinctions. Maple is the application name.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The account details were not accepted. Try again." + } + } + } + }, + "The draft for this image is no longer open.": { + "comment": "Image attachment selection, eligibility, or import feedback. These messages refer to the current draft.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The draft for this image is no longer open." + } + } + } + }, + "The image could not be imported. Your existing draft is unchanged.": { + "comment": "Image attachment selection, eligibility, or import feedback. These messages refer to the current draft.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The image could not be imported. Your existing draft is unchanged." + } + } + } + }, + "The request couldn’t finish. Your unsent work is preserved.": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The request couldn’t finish. Your unsent work is preserved." + } + } + } + }, + "The response is still being checked. Your message will not be sent again.": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The response is still being checked. Your message will not be sent again." + } + } + } + }, + "The secure connection’s certificate is not valid at the device’s current date and time. Try again in a moment. If this continues, check Date & Time and the time zone in Settings.": { + "comment": "Safe guidance for a typed SDK attestation time failure. Do not imply trust validation can be bypassed. Settings refers to device system Settings.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The secure connection’s certificate is not valid at the device’s current date and time. Try again in a moment. If this continues, check Date & Time and the time zone in Settings." + } + } + } + }, + "The selected model is unavailable. Choose an available model.": { + "comment": "Read-only explanation or VoiceOver state for an unavailable image, dictation, read-aloud, or web-search control. Distinguish unknown account details from a confirmed plan, quota, or capability restriction; this screen does not offer purchases.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The selected model is unavailable. Choose an available model." + } + } + } + }, + "The selected model isn’t currently available.": { + "comment": "Account-access status shown beside the composer; preserve the distinction between unavailable details and a confirmed restriction.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The selected model isn’t currently available." + } + } + } + }, + "Thinking": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Thinking" + } + } + } + }, + "This build needs a valid service configuration.": { + "comment": "Account startup, restoration, or protected-storage cleanup interface. Maple is the application name and stays unchanged.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This build needs a valid service configuration." + } + } + } + }, + "This device’s date or time appears incorrect. Check Date & Time and the time zone in Settings, then try again.": { + "comment": "Safe guidance for a typed SDK attestation time failure. Do not imply trust validation can be bypassed. Settings refers to device system Settings.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This device’s date or time appears incorrect. Check Date & Time and the time zone in Settings, then try again." + } + } + } + }, + "This feature is available for your account.": { + "comment": "Read-only explanation or VoiceOver state for an unavailable image, dictation, read-aloud, or web-search control. Distinguish unknown account details from a confirmed plan, quota, or capability restriction; this screen does not offer purchases.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This feature is available for your account." + } + } + } + }, + "This guest account can’t start a chat. Sign in with an eligible account.": { + "comment": "Account-access status shown beside the composer; preserve the distinction between unavailable details and a confirmed restriction.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This guest account can’t start a chat. Sign in with an eligible account." + } + } + } + }, + "This image could not be read. Choose a PNG, JPEG, WebP, or HEIF image.": { + "comment": "Image attachment selection, eligibility, or import feedback. These messages refer to the current draft.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This image could not be read. Choose a PNG, JPEG, WebP, or HEIF image." + } + } + } + }, + "This image is too large. Choose an image smaller than 20 MB.": { + "comment": "Image attachment selection, eligibility, or import feedback. These messages refer to the current draft.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This image is too large. Choose an image smaller than 20 MB." + } + } + } + }, + "This link type cannot be opened by Maple.": { + "comment": "Confirmation or navigation in the user-opened external browser. This browser is separate from chat content.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This link type cannot be opened by Maple." + } + } + } + }, + "This message is too large. Shorten it and try again.": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This message is too large. Shorten it and try again." + } + } + } + }, + "This model requires a paid plan. Choose an available model.": { + "comment": "Account-access status shown beside the composer; preserve the distinction between unavailable details and a confirmed restriction.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This model requires a paid plan. Choose an available model." + } + } + } + }, + "This page couldn’t be loaded.": { + "comment": "Confirmation or navigation in the user-opened external browser. This browser is separate from chat content.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This page couldn’t be loaded." + } + } + } + }, + "This permanently deletes your chats and their saved drafts and queued messages. Active requests must stop before deletion can finish.": { + "comment": "Account settings startup or permanent chat-history deletion confirmation.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This permanently deletes your chats and their saved drafts and queued messages. Active requests must stop before deletion can finish." + } + } + } + }, + "This selection exceeds your current attachment limits. Existing images are unchanged.": { + "comment": "Image attachment selection, eligibility, or import feedback. These messages refer to the current draft.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This selection exceeds your current attachment limits. Existing images are unchanged." + } + } + } + }, + "Try again": { + "comment": "Account startup, restoration, or protected-storage cleanup interface. Maple is the application name and stays unchanged.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Try again" + } + } + } + }, + "Unable to open your account": { + "comment": "Account startup, restoration, or protected-storage cleanup interface. Maple is the application name and stays unchanged.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unable to open your account" + } + } + } + }, + "Unavailable": { + "comment": "Read-only explanation or VoiceOver state for an unavailable image, dictation, read-aloud, or web-search control. Distinguish unknown account details from a confirmed plan, quota, or capability restriction; this screen does not offer purchases.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unavailable" + } + } + } + }, + "Unfinished messages": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unfinished messages" + } + } + } + }, + "Use an existing guest account": { + "comment": "Email sign-in, signup, or existing guest-account sign-in form. Preserve existing-account versus new-account distinctions. Maple is the application name.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use an existing guest account" + } + } + } + }, + "View attached image": { + "comment": "Attached-image preview, fullscreen zoom, dismissal, or VoiceOver action.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "View attached image" + } + } + } + }, + "View message image": { + "comment": "Attached-image preview, fullscreen zoom, dismissal, or VoiceOver action.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "View message image" + } + } + } + }, + "We couldn’t connect. Check your connection and try again.": { + "comment": "Email sign-in, signup, or existing guest-account sign-in form. Preserve existing-account versus new-account distinctions. Maple is the application name.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "We couldn’t connect. Check your connection and try again." + } + } + } + }, + "Web search": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Web search" + } + } + } + }, + "Web sources": { + "comment": "Compact chat tool disclosure. Use a friendly description instead of an internal tool identifier; failures remain visible and actionable.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Web sources" + } + } + } + }, + "Web tool": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Web tool" + } + } + } + }, + "Welcome back": { + "comment": "Email sign-in, signup, or existing guest-account sign-in form. Preserve existing-account versus new-account distinctions. Maple is the application name.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Welcome back" + } + } + } + }, + "Working…": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Working…" + } + } + } + }, + "You": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You" + } + } + } + }, + "Your account has reached its current usage limit.": { + "comment": "Read-only explanation or VoiceOver state for an unavailable image, dictation, read-aloud, or web-search control. Distinguish unknown account details from a confirmed plan, quota, or capability restriction; this screen does not offer purchases.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your account has reached its current usage limit." + } + } + } + }, + "Your draft hasn’t been saved. Unlock your device and try again.": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your draft hasn’t been saved. Unlock your device and try again." + } + } + } + }, + "Your plan details are unavailable. Refresh account status and try again.": { + "comment": "Read-only explanation or VoiceOver state for an unavailable image, dictation, read-aloud, or web-search control. Distinguish unknown account details from a confirmed plan, quota, or capability restriction; this screen does not offer purchases.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your plan details are unavailable. Refresh account status and try again." + } + } + } + }, + "Your saved work is preserved. Try again when your connection and protected storage are available.": { + "comment": "Account startup, restoration, or protected-storage cleanup interface. Maple is the application name and stays unchanged.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your saved work is preserved. Try again when your connection and protected storage are available." + } + } + } + }, + "Your session has ended.": { + "comment": "Account-access status shown beside the composer; preserve the distinction between unavailable details and a confirmed restriction.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your session has ended." + } + } + } + }, + "Your session has ended. Sign in again.": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your session has ended. Sign in again." + } + } + } + }, + "Your team’s seat allocation needs attention. Contact your team administrator.": { + "comment": "Read-only explanation or VoiceOver state for an unavailable image, dictation, read-aloud, or web-search control. Distinguish unknown account details from a confirmed plan, quota, or capability restriction; this screen does not offer purchases.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your team’s seat allocation needs attention. Contact your team administrator." + } + } + } + }, + "You’re offline. Your draft is saved when protected storage is available.": { + "comment": "Chat composer, transcript, response progress, or message action. Control labels are also read by VoiceOver.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You’re offline. Your draft is saved when protected storage is available." + } + } + } + }, + "You’ve reached your current chat allowance.": { + "comment": "Account-access status shown beside the composer; preserve the distinction between unavailable details and a confirmed restriction.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You’ve reached your current chat allowance." + } + } + } + }, + "Zoom in": { + "comment": "Attached-image preview, fullscreen zoom, dismissal, or VoiceOver action.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Zoom in" + } + } + } + }, + "Zoom out": { + "comment": "Attached-image preview, fullscreen zoom, dismissal, or VoiceOver action.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Zoom out" + } + } + } + }, + "browser.external-link-message": { + "comment": "External-link confirmation. %@ is the complete destination URL; preserve it without translation or modification. Maple is the application name.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You’re leaving Maple content.\n%@" + } + } + } + }, + "chat.image-count": { + "comment": "Number of image attachments belonging to one queued message.", + "extractionState": "manual", + "localizations": { + "en": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld image" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld images" + } + } + } + } + } + } + }, + "chat.queued-message-count": { + "comment": "Number of waiting messages shown on the composer queue-review button. The count excludes a response already running.", + "extractionState": "manual", + "localizations": { + "en": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld queued message" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld queued messages" + } + } + } + } + } + } + }, + "You've reached your daily usage limit. Upgrade to Pro for more chats.": { + "comment": "Research usage-limit feedback for a Free account or missing/empty billing product name; upgrade is explanatory copy only.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You've reached your daily usage limit. Upgrade to Pro for more chats." + } + } + } + }, + "You've reached your monthly Pro limit. Upgrade to Max for 10x more usage.": { + "comment": "Research usage-limit feedback for product names containing Pro and not Max; preserve the plan names and multiplier.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You've reached your monthly Pro limit. Upgrade to Max for 10x more usage." + } + } + } + }, + "You've reached your monthly usage limit. Please wait for the next billing cycle.": { + "comment": "Research usage-limit feedback for any other nonempty billing product name, including unrecognized names.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You've reached your monthly usage limit. Please wait for the next billing cycle." + } + } + } + }, + "This conversation is too long for the free tier. Upgrade to Pro for longer conversations.": { + "comment": "Research free-tier conversation token-limit feedback, distinct from a single-message context limit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This conversation is too long for the free tier. Upgrade to Pro for longer conversations." + } + } + } + }, + "Your message exceeds the context limit for this model.": { + "comment": "Research rejected-message context-limit feedback. The message, not the conversation, exceeded the model limit.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your message exceeds the context limit for this model." + } + } + } + }, + "This model or feature is not available on your current plan.": { + "comment": "Research server plan-access-denial feedback.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This model or feature is not available on your current plan." + } + } + } + }, + "Failed to generate response. Please try again.": { + "comment": "Research generation-failure feedback; partial reply text stays visible.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to generate response. Please try again." + } + } + } + }, + "Stopped before sending. Your message was restored.": { + "comment": "Research feedback when Stop prevented dispatch and the draft was restored.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stopped before sending. Your message was restored." + } + } + } + }, + "Stopping as soon as the response is ready…": { + "comment": "Research feedback while Stop is waiting for the server response identifier.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stopping as soon as the response is ready…" + } + } + } + }, + "The response finished before it could be stopped.": { + "comment": "Research feedback when a requested Stop raced a completed response.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The response finished before it could be stopped." + } + } + } + }, + "The response completed after the connection was restored. Queued messages were kept.": { + "comment": "Research successful completion after connection recovery; queued work was retained.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The response completed after the connection was restored. Queued messages were kept." + } + } + } + }, + "The interrupted response stopped. Queued messages were kept.": { + "comment": "Research stopped response after connection recovery; queued work was retained.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The interrupted response stopped. Queued messages were kept." + } + } + } + }, + "Image description is temporarily unavailable. Your message and images were restored; please try again.": { + "comment": "Research image-description capacity feedback after restoring the rejected draft and its images.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Image description is temporarily unavailable. Your message and images were restored; please try again." + } + } + } + }, + "Inference capacity is temporarily unavailable. Your message was restored. Please try again shortly.": { + "comment": "Research inference-capacity feedback after restoring the rejected draft.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Inference capacity is temporarily unavailable. Your message was restored. Please try again shortly." + } + } + } + }, + "Message Too Large": { + "comment": "Title of the Research context-limit alert.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Message Too Large" + } + } + } + }, + "Got it": { + "comment": "Dismiss button in the Research message-context-limit alert.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Got it" + } + } + } + }, + "Your message exceeds the context limit for the current model.\n\nHere's what you can try:\nShorten your message - Try reducing the amount of text or content": { + "comment": "Research context-limit dialog text, without the document suggestion because native document uploads are outside this pass.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your message exceeds the context limit for the current model.\n\nHere's what you can try:\nShorten your message - Try reducing the amount of text or content" + } + } + } + }, + "Chat Canceled": { + "comment": "Research status caption on incomplete assistant output after Stop; retain US spelling.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chat Canceled" + } + } + } + }, + "Copied": { + "comment": "Research two-second confirmation label after copying a whole message or code block.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copied" + } + } + } + }, + "Message Maple...": { + "comment": "Research composer placeholder; Maple is the application name and the punctuation is three periods.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Message Maple..." + } + } + } + }, + "AI can make mistakes. Check important info.": { + "comment": "Research informational footnote shown below the docked composer; the empty new-chat composer shows the privacy reassurance instead.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "AI can make mistakes. Check important info." + } + } + } + }, + "chat.thought-duration": { + "comment": "Research completed reasoning label. Duration is estimated from word count; preserve the complete message.", + "extractionState": "manual", + "localizations": { + "en": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Thought for %lld seconds" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Thought for %lld seconds" + } + } + } + } + } + } + }, + "Maple is responding": { + "comment": "Research VoiceOver label for the pending response progress indicator before assistant text arrives.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Maple is responding" + } + } + } + }, + "Chat drafts and queued attachments can use up to 256 MiB across your account": { + "comment": "Research account retained-attachment byte budget notice.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chat drafts and queued attachments can use up to 256 MiB across your account" + } + } + } + }, + "Chat drafts and queued messages can retain up to 640 images across your account": { + "comment": "Research account retained-image budget notice.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chat drafts and queued messages can retain up to 640 images across your account" + } + } + } + }, + "In progress": { + "comment": "Research tool activity status or detail control.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "In progress" + } + } + } + }, + "Input": { + "comment": "Research tool activity status or detail control.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Input" + } + } + } + }, + "Open URL": { + "comment": "Research tool activity status or detail control.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open URL" + } + } + } + }, + "Open URLs": { + "comment": "Research tool activity status or detail control.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open URLs" + } + } + } + }, + "Output": { + "comment": "Research tool activity status or detail control.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Output" + } + } + } + }, + "Read image": { + "comment": "Research tool activity status or detail control.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Read image" + } + } + } + }, + "Show less": { + "comment": "Research tool activity status or detail control.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show less" + } + } + } + }, + "Show more": { + "comment": "Research tool activity status or detail control.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show more" + } + } + } + }, + "Tool call": { + "comment": "Research tool activity status or detail control.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tool call" + } + } + } + }, + "chat.tool-image-title": { + "comment": "Tool card title. Placeholder is the image number.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Read image: Image %@" + } + } + } + }, + "chat.tool-named-query-title": { + "comment": "Unknown tool title. First placeholder is the tool name; second is its query.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@: \"%2$@\"" + } + } + } + }, + "chat.tool-search-title": { + "comment": "Tool card title. Placeholder is the search query.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Web Search: \"%@\"" + } + } + } + }, + "chat.tool-url-title": { + "comment": "Tool card title. Placeholder is the URL.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open URL: %@" + } + } + } + }, + "chat.tool-urls-title": { + "comment": "Tool card title for multiple pages.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open URLs: %lld pages" + } + } + } + }, + "chat_issue_conversation_deleting": { + "comment": "Research deletion admission notice. Do not expose server error bodies.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sending paused because this conversation is being deleted." + } + } + } + }, + "Loading earlier messages": { + "comment": "Accessibility for the older-history loading spinner.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading earlier messages" + } + } + } + }, + "No content available": { + "comment": "Empty legacy document history preview.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No content available" + } + } + } + }, + "Tool result": { + "comment": "An orphan tool output card title.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tool result" + } + } + } + }, + "chat.document-preview": { + "comment": "History document chip. Placeholder is its original filename.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preview document: %@" + } + } + } + }, + "Team usage paused": { + "comment": "Read-only mobile team seat mismatch notice. Member and paid-seat counts can independently be singular. No purchase or management action.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Team usage paused" + } + } + } + }, + "Seat count mismatch": { + "comment": "Read-only mobile team seat mismatch notice. Member and paid-seat counts can independently be singular. No purchase or management action.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Seat count mismatch" + } + } + } + }, + "This team has more members than paid seats. Remove members to match the paid seat count and resume team usage.": { + "comment": "Read-only mobile team seat mismatch notice. Member and paid-seat counts can independently be singular. No purchase or management action.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This team has more members than paid seats. Remove members to match the paid seat count and resume team usage." + } + } + } + }, + "This team has more members than paid seats. Contact your team admin to resolve the seat count.": { + "comment": "Read-only mobile team seat mismatch notice. Member and paid-seat counts can independently be singular. No purchase or management action.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This team has more members than paid seats. Contact your team admin to resolve the seat count." + } + } + } + }, + "team.seat-mismatch-summary": { + "comment": "Read-only mobile team seat mismatch notice. Member and paid-seat counts can independently be singular. No purchase or management action.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%#@members@, %#@seats@" + }, + "substitutions": { + "members": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld member" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld members" + } + } + } + } + }, + "seats": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld paid seat" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld paid seats" + } + } + } + } + } + } + } + } + }, + "team.seat-mismatch-admin-message": { + "comment": "Read-only mobile team seat mismatch notice. Member and paid-seat counts can independently be singular. No purchase or management action.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This team has %#@members@ but only %#@seats@. Remove members to match the paid seat count and resume team usage." + }, + "substitutions": { + "members": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld member" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld members" + } + } + } + } + }, + "seats": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld paid seat" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld paid seats" + } + } + } + } + } + } + } + } + }, + "team.seat-mismatch-member-message": { + "comment": "Read-only mobile team seat mismatch notice. Member and paid-seat counts can independently be singular. No purchase or management action.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This team has %#@members@ but only %#@seats@. Contact your team admin to resolve the seat count." + }, + "substitutions": { + "members": { + "argNum": 1, + "formatSpecifier": "lld", + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld member" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld members" + } + } + } + } + }, + "seats": { + "argNum": 2, + "formatSpecifier": "lld", + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld paid seat" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld paid seats" + } + } + } + } + } + } + } + } + }, + "chat.tool-search-name": { + "comment": "Research tool activity status or detail control.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Web Search" + } + } + } + } + }, + "version": "1.0" +} diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/ThirdPartyNotices/ConcurrencyExtras-MIT.txt b/apps/maple-ios/Sources/MapleAppUI/Resources/ThirdPartyNotices/ConcurrencyExtras-MIT.txt new file mode 100644 index 000000000..a27363afb --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Resources/ThirdPartyNotices/ConcurrencyExtras-MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Point-Free + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/ThirdPartyNotices/Maple-MIT.txt b/apps/maple-ios/Sources/MapleAppUI/Resources/ThirdPartyNotices/Maple-MIT.txt new file mode 100644 index 000000000..6a68625a2 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Resources/ThirdPartyNotices/Maple-MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Open Secret + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/ThirdPartyNotices/MapleSDK-MIT.txt b/apps/maple-ios/Sources/MapleAppUI/Resources/ThirdPartyNotices/MapleSDK-MIT.txt new file mode 100644 index 000000000..19f8cb7db --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Resources/ThirdPartyNotices/MapleSDK-MIT.txt @@ -0,0 +1,7 @@ +Copyright (c) 2024 OpenSecret + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/ThirdPartyNotices/PROVENANCE.json b/apps/maple-ios/Sources/MapleAppUI/Resources/ThirdPartyNotices/PROVENANCE.json new file mode 100644 index 000000000..6a8fc6a98 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Resources/ThirdPartyNotices/PROVENANCE.json @@ -0,0 +1,135 @@ +{ + "formatVersion": 1, + "notices": [ + { + "component": "Textual", + "notice": "Textual-MIT.txt", + "sourceRepository": "https://github.com/gonzalezreal/textual", + "sourceRevision": "01b51875a5406eefc95f52a058cb059e7bc94dc4", + "sourcePath": "LICENSE", + "sha256": "9c9191ada6888f3da1a888b6c74dae46e98787b1150c734e3b1e5ec5bbf73b09" + }, + { + "component": "SwiftUIMath", + "notice": "SwiftUIMath-MIT.txt", + "sourceRepository": "https://github.com/gonzalezreal/swiftui-math", + "sourceRevision": "0b5c2cfaaec8d6193db206f675048eeb5ce95f71", + "sourcePath": "LICENSE", + "sha256": "10023a3a8bab8cd2206023303aa8d104a9d1cfb184e18a2f7a48b5b34fc0bb8f" + }, + { + "component": "ConcurrencyExtras", + "notice": "ConcurrencyExtras-MIT.txt", + "sourceRepository": "https://github.com/pointfreeco/swift-concurrency-extras", + "sourceRevision": "82a4ae7170d98d8538ec77238b7eb8e7199ef2e8", + "sourcePath": "LICENSE", + "sha256": "72565151804cdc168937ccc70e4c30c5afff92ff314bb9d504780a3ad1c1f868" + }, + { + "component": "Prism.js", + "notice": "PrismJS-MIT.txt", + "sourceRepository": "https://github.com/PrismJS/prism", + "sourceRevision": "59e5a3471377057de1f401ba38337aca27b80e03", + "sourcePath": "LICENSE", + "sha256": "2b947f0901a7ffcf08a89957da9783c0e9c6e72cb6ce8e959f501ab5409e4d2b", + "sourceTag": "v1.29.0", + "sourceTagObject": "8893d805d9a1287de497364079127c6eb26c97c6", + "sourceURL": "https://raw.githubusercontent.com/PrismJS/prism/59e5a3471377057de1f401ba38337aca27b80e03/LICENSE", + "distributedBy": "Textual", + "distributedResource": "Sources/Textual/Internal/Highlighter/Prism/prism-bundle.js", + "distributedResourceSHA256": "22b70192dc9d73377b5b8fe2c85655c7e3218afd37b65db638b4e168c4bacbf5" + }, + { + "component": "Maple", + "notice": "Maple-MIT.txt", + "sourceRepository": "https://github.com/MaplePrivacyLabs/Maple", + "sourceRevision": "6499617122cdf9b2b2e44de1cea1ed07486cde34", + "sourcePath": "LICENSE", + "sha256": "076f3390f6342f51a64feadb9cd84d4ca41f56ebc6c2f9f1fe98fa255d853ad8" + }, + { + "component": "Maple SDK", + "notice": "MapleSDK-MIT.txt", + "sourceRepository": "https://github.com/MaplePrivacyLabs/Maple", + "sourceRevision": "6499617122cdf9b2b2e44de1cea1ed07486cde34", + "sourcePath": "sdk/LICENSE.md", + "sha256": "2cbf426b424ec7a96a18a465e75583e07e1af3356187d4b6dacb6ed152b96656" + } + ], + "textualProvenance": [ + { + "file": "Textual-UPSTREAM.json", + "sha256": "6916033e1a954b28df59ba53ad247a255848a6425ce954a794ae8beacd3f3c5e" + }, + { + "file": "Textual-MAPLE-PATCH.md", + "sha256": "f9c0d5bd527d69a8a6961e62a0f8b23262f23dbc8c0c8cf772a105f0a20763a5" + } + ], + "otherBundledNotices": [ + { + "component": "KaTeX", + "sourcePath": "apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/LICENSE", + "bundlePath": "MapleApp_MapleAppUI.bundle/EquationRenderer/LICENSE", + "sha256": "766ccc1f306c885aa45542a9846bbd0a505b27a0374f146778171c2254ce18e3" + }, + { + "component": "KaTeX fonts", + "sourcePath": "apps/maple-ios/Sources/MapleAppUI/Resources/EquationRenderer/FONT-LICENSE.txt", + "bundlePath": "MapleApp_MapleAppUI.bundle/EquationRenderer/FONT-LICENSE.txt", + "sha256": "35b07f5841b2ea07da02a759e6751bb5bbcc5e29586c1c2cb3748bce06b2ca13" + }, + { + "component": "Array", + "sourcePath": "apps/maple-ios/Resources/FontLicenses/Array-FFL.txt", + "bundlePath": "FontLicenses/Array-FFL.txt", + "sha256": "145e7fe2429a3336ba215c070ef722000e01348a3e1baaa127e871bb5012f554" + }, + { + "component": "Manrope", + "sourcePath": "apps/maple-ios/Resources/FontLicenses/Manrope-OFL.txt", + "bundlePath": "FontLicenses/Manrope-OFL.txt", + "sha256": "e01b637272e0cbdfb240184dd98ea5cc671556d9894dae2668d92ab2c906787c" + }, + { + "component": "SwiftUIMath fonts", + "sourceRepository": "https://github.com/gonzalezreal/swiftui-math", + "sourceRevision": "0b5c2cfaaec8d6193db206f675048eeb5ce95f71", + "sourcePath": "Sources/SwiftUIMath/mathFonts.bundle/LICENSE", + "bundlePath": "swiftui-math_SwiftUIMath.bundle/mathFonts.bundle/LICENSE", + "sha256": "431b2cb82b8a55977323598231f1baa44f32accac1231b4781a9a393d0bc29ed" + }, + { + "component": "SwiftUIMath fonts", + "sourceRepository": "https://github.com/gonzalezreal/swiftui-math", + "sourceRevision": "0b5c2cfaaec8d6193db206f675048eeb5ce95f71", + "sourcePath": "Sources/SwiftUIMath/mathFonts.bundle/OFL.txt", + "bundlePath": "swiftui-math_SwiftUIMath.bundle/mathFonts.bundle/OFL.txt", + "sha256": "a96bed88f867f6a5bbe78ced4ca52a3ce6e002475363d86edd9097b2f8206d8d" + }, + { + "component": "SwiftUIMath fonts", + "sourceRepository": "https://github.com/gonzalezreal/swiftui-math", + "sourceRevision": "0b5c2cfaaec8d6193db206f675048eeb5ce95f71", + "sourcePath": "Sources/SwiftUIMath/mathFonts.bundle/GUST-FONT-LICENSE.txt", + "bundlePath": "swiftui-math_SwiftUIMath.bundle/mathFonts.bundle/GUST-FONT-LICENSE.txt", + "sha256": "2bd69affc3da00715116f713f57eab9707e96daf3562ad0215987b15b9c16f73" + }, + { + "component": "Geist Mono", + "sourceRepository": "https://github.com/vercel/geist-font", + "sourceTag": "v1.7.2", + "sourcePath": "apps/maple-ios/Resources/FontLicenses/GeistMono-OFL.txt", + "bundlePath": "FontLicenses/GeistMono-OFL.txt", + "sha256": "c683bfbcc7e087f5d37a54ef628f10387c451a83ddc459b151403a164ac46c90" + }, + { + "component": "Geist Mono", + "sourceRepository": "https://github.com/vercel/geist-font", + "sourceTag": "v1.7.2", + "sourcePath": "apps/maple-ios/Sources/MapleAppUI/Resources/Fonts/GeistMono-OFL.txt", + "bundlePath": "MapleApp_MapleAppUI.bundle/Fonts/GeistMono-OFL.txt", + "sha256": "c683bfbcc7e087f5d37a54ef628f10387c451a83ddc459b151403a164ac46c90" + } + ] +} diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/ThirdPartyNotices/PrismJS-MIT.txt b/apps/maple-ios/Sources/MapleAppUI/Resources/ThirdPartyNotices/PrismJS-MIT.txt new file mode 100644 index 000000000..528949f42 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Resources/ThirdPartyNotices/PrismJS-MIT.txt @@ -0,0 +1,21 @@ +MIT LICENSE + +Copyright (c) 2012 Lea Verou + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/ThirdPartyNotices/README.md b/apps/maple-ios/Sources/MapleAppUI/Resources/ThirdPartyNotices/README.md new file mode 100644 index 000000000..440020231 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Resources/ThirdPartyNotices/README.md @@ -0,0 +1,25 @@ +# Software notices + +These license texts are reproduced verbatim for the Maple app and the Swift +runtime packages it consumes. `PROVENANCE.json` records the exact source +revision and SHA-256 of each notice. The MapleAppUI resource target copies this +whole directory into the application without localization or text conversion. + +Textual is the locally vendored runtime. `Textual-UPSTREAM.json` records every +production file's upstream and local digest; `Textual-MAPLE-PATCH.md` describes +the bounded Copy export and embedded-control interaction corrections. Textual also packages Prism.js 1.29.0. Its +complete MIT notice is included even though Maple uses its own code highlighting path. +The Prism notice was verified against both its official v1.29.0 tag and the +resolved commit. The packaged JS digest matches Textual's pinned upstream file. + +SwiftUIMath and ConcurrencyExtras are pinned Swift package runtime dependencies. +SwiftUIMath's copied math font bundle already contains its original MIT, OFL, +and GUST notices. KaTeX and its fonts retain their original notices in the +EquationRenderer resource directory. Array and Manrope notices remain in the +app's FontLicenses directory. The corresponding paths and hashes are also +listed in `PROVENANCE.json`; the font files and notices are not modified here. + +Upstream test-only packages such as SnapshotTesting are not runtime products +in Maple's resolved package graph. System frameworks are supplied by iOS. +Updating a dependency requires refreshing the relevant notice and provenance, +and checking the final app bundle; a source copy alone is not packaging proof. diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/ThirdPartyNotices/SwiftUIMath-MIT.txt b/apps/maple-ios/Sources/MapleAppUI/Resources/ThirdPartyNotices/SwiftUIMath-MIT.txt new file mode 100644 index 000000000..6f066cab3 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Resources/ThirdPartyNotices/SwiftUIMath-MIT.txt @@ -0,0 +1,23 @@ +MIT License + +Copyright (c) 2026 Guille Gonzalez +Copyright (c) 2023 Computer Inspirations (SwiftMath) +Copyright (c) 2013 MathChat (iosMath) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/ThirdPartyNotices/Textual-MAPLE-PATCH.md b/apps/maple-ios/Sources/MapleAppUI/Resources/ThirdPartyNotices/Textual-MAPLE-PATCH.md new file mode 100644 index 000000000..e8a02537c --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Resources/ThirdPartyNotices/Textual-MAPLE-PATCH.md @@ -0,0 +1,17 @@ +# Pinned Textual runtime + +Upstream: https://github.com/gonzalezreal/textual.git, revision `01b51875a5406eefc95f52a058cb059e7bc94dc4` (MIT, license retained verbatim). + +The 193 production source/resource files are copied from that verified clean commit with four bounded changes to Copy export and embedded control interaction: + +- `Internal/Formatting/Formatter+PlainText.swift` exports an explicit line break as LF instead of a space. +- `Internal/Formatting/Formatter+HTML.swift` exports literal LF in ordinary text nodes as `
`. Native attributed selection can retain the visible LF while losing the inline line-break intent. Encoding that LF as an HTML whitespace entity collapses it to a space on import. Escaping, code nodes and preformatted code blocks retain their existing behavior. +- `Internal/TextInteraction/UIKit/UITextInteractionView.swift` publishes the plain representation as explicit UTF-8 bytes under `public.utf8-plain-text`, alongside the HTML representation. Plain text retains canonical LF even when HTML consumers use other line-separator conventions. + +- `StructuredText/Style/Overflow.swift` adds a scoped `textSelectionInteractionExcluded()` modifier for embedded controls. It publishes the control's actual bounds through the existing selection-overlay exclusion preference. Maple applies it to the code Copy button, whose fixed header is outside the horizontally scrolling code region. This allows ordinary button taps to reach the control while leaving the surrounding text selectable. + +The changes preserve native selection, paragraph structure, inline formatting and rich HTML Copy. They do not modify transport, caching, JavaScript or dependency versions. Focused tests cover the native attributed-string bridge, plain and HTML breaks, escaped user text, Unicode, inline formatting and code whitespace. Native partial-selection Copy and embedded Copy-button delivery remain separate simulator validation gates; unit-level HTML import does not prove UIKit clipboard behavior. + +The Swift 6.0 package manifest retains upstream platform/product/production target settings and dependency requirements, excluding only the upstream standalone SnapshotTesting dependency and test target. Those upstream snapshots/fixtures are not vendored. Maple retains its exact ConcurrencyExtras and SwiftUIMath revision pins and adds focused regression tests through its existing UI unit-test target. UPSTREAM.json records SHA-256 for every copied production file before and after these patches. + +Do not hand-patch SwiftPM build checkouts. Future updates should regenerate provenance from an exact clean upstream revision and re-evaluate whether these Copy fixes are still needed. The upstream MIT notice must remain with distributed code. diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/ThirdPartyNotices/Textual-MIT.txt b/apps/maple-ios/Sources/MapleAppUI/Resources/ThirdPartyNotices/Textual-MIT.txt new file mode 100644 index 000000000..8d95cd035 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Resources/ThirdPartyNotices/Textual-MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Guille Gonzalez + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/maple-ios/Sources/MapleAppUI/Resources/ThirdPartyNotices/Textual-UPSTREAM.json b/apps/maple-ios/Sources/MapleAppUI/Resources/ThirdPartyNotices/Textual-UPSTREAM.json new file mode 100644 index 000000000..d41a51aee --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Resources/ThirdPartyNotices/Textual-UPSTREAM.json @@ -0,0 +1,971 @@ +{ + "repository": "https://github.com/gonzalezreal/textual.git", + "revision": "01b51875a5406eefc95f52a058cb059e7bc94dc4", + "files": [ + { + "path": "Sources/Textual/Attachment/Attachment.swift", + "upstream_sha256": "194bbb96b44f530c1f6d810e26188dc1a51c0faa486936b8336f220c8475022f", + "local_sha256": "194bbb96b44f530c1f6d810e26188dc1a51c0faa486936b8336f220c8475022f" + }, + { + "path": "Sources/Textual/Attachment/AttachmentLoader.swift", + "upstream_sha256": "b080e2dd3c88790dea7e90eb784fb7e069d2d81c9265d4c505ddfceb8755d7e8", + "local_sha256": "b080e2dd3c88790dea7e90eb784fb7e069d2d81c9265d4c505ddfceb8755d7e8" + }, + { + "path": "Sources/Textual/Attachment/ResourceAttachmentLoader.swift", + "upstream_sha256": "ffbe2496d93b8d630dbfa40415ebb03c5c0b9987e74469504cd8211e111dc686", + "local_sha256": "ffbe2496d93b8d630dbfa40415ebb03c5c0b9987e74469504cd8211e111dc686" + }, + { + "path": "Sources/Textual/Attachment/URLAttachmentLoader.swift", + "upstream_sha256": "0ad07de18bfc4167d155d96fecf9dcf0fc2be716e94de4305cee3bca82b43a14", + "local_sha256": "0ad07de18bfc4167d155d96fecf9dcf0fc2be716e94de4305cee3bca82b43a14" + }, + { + "path": "Sources/Textual/ColorEnvironmentValues.swift", + "upstream_sha256": "664907af71fd6c4db06334215b52ee9b9ed1a8571272639cb9b426badc9a17ab", + "local_sha256": "664907af71fd6c4db06334215b52ee9b9ed1a8571272639cb9b426badc9a17ab" + }, + { + "path": "Sources/Textual/Deprecations.swift", + "upstream_sha256": "5432e9b15ea537bd735cd7f1a2b025d6feaf23124f58b7841e4e5f02ce6d508e", + "local_sha256": "5432e9b15ea537bd735cd7f1a2b025d6feaf23124f58b7841e4e5f02ce6d508e" + }, + { + "path": "Sources/Textual/DynamicColor.swift", + "upstream_sha256": "957bf905ced0ebbb585de2c062a0a800f6fd94dd0ff5cef8eba317d2aba816c5", + "local_sha256": "957bf905ced0ebbb585de2c062a0a800f6fd94dd0ff5cef8eba317d2aba816c5" + }, + { + "path": "Sources/Textual/EmojiProperties.swift", + "upstream_sha256": "f01a974badab3dceedeae18de47e2506a803e8d41583fb924cf00d77f2a55ede", + "local_sha256": "f01a974badab3dceedeae18de47e2506a803e8d41583fb924cf00d77f2a55ede" + }, + { + "path": "Sources/Textual/FontScaled.swift", + "upstream_sha256": "6b36a2b63ea3d789b62b611f2170deda6e1cdec2ff5460c0ce236dc73c70cf2c", + "local_sha256": "6b36a2b63ea3d789b62b611f2170deda6e1cdec2ff5460c0ce236dc73c70cf2c" + }, + { + "path": "Sources/Textual/InlineText/InlineStyle+Default.swift", + "upstream_sha256": "00c82086f15281ec01c5ccf4da888ccde4bbd967ea6e90a3a7c0997b20501d93", + "local_sha256": "00c82086f15281ec01c5ccf4da888ccde4bbd967ea6e90a3a7c0997b20501d93" + }, + { + "path": "Sources/Textual/InlineText/InlineStyle+GitHub.swift", + "upstream_sha256": "6e2db417677c0bc8e88e65f03049d7dafa42e6a5b0a8ce8e50ac72029d10272d", + "local_sha256": "6e2db417677c0bc8e88e65f03049d7dafa42e6a5b0a8ce8e50ac72029d10272d" + }, + { + "path": "Sources/Textual/InlineText/InlineStyle.swift", + "upstream_sha256": "f59e60491c16dc24152c20c09769c6b70a1084250587e24cb69bfabab899dd70", + "local_sha256": "f59e60491c16dc24152c20c09769c6b70a1084250587e24cb69bfabab899dd70" + }, + { + "path": "Sources/Textual/InlineText/InlineText.swift", + "upstream_sha256": "4fcb9ab06dfc5477ab51158a7117a74d0c7de3b85e6a73f9aa39c59861605598", + "local_sha256": "4fcb9ab06dfc5477ab51158a7117a74d0c7de3b85e6a73f9aa39c59861605598" + }, + { + "path": "Sources/Textual/Internal/Attachment/AttachmentAttribute.swift", + "upstream_sha256": "230c2da09b4e87335968ec6f23afd4a9bd812a9c309a87510c5a7d4585f58fba", + "local_sha256": "230c2da09b4e87335968ec6f23afd4a9bd812a9c309a87510c5a7d4585f58fba" + }, + { + "path": "Sources/Textual/Internal/Attachment/AttachmentOverlay.swift", + "upstream_sha256": "070ce9009e08f2da3e16a40b440878326873c9443e1be043cc097d79d72f3359", + "local_sha256": "070ce9009e08f2da3e16a40b440878326873c9443e1be043cc097d79d72f3359" + }, + { + "path": "Sources/Textual/Internal/Attachment/AttachmentView.swift", + "upstream_sha256": "3227b5c219cd7cc3eca80c959cefab02871ed88f7e062a3e375ea861e742f36c", + "local_sha256": "3227b5c219cd7cc3eca80c959cefab02871ed88f7e062a3e375ea861e742f36c" + }, + { + "path": "Sources/Textual/Internal/Attachment/EmojiAttachment.swift", + "upstream_sha256": "fee5696a7201e80266320a70127c975a937cc2a540353107eb0efc5ecbb5def0", + "local_sha256": "fee5696a7201e80266320a70127c975a937cc2a540353107eb0efc5ecbb5def0" + }, + { + "path": "Sources/Textual/Internal/Attachment/EmojiResourceAttachment.swift", + "upstream_sha256": "f5550702de4ebf52626a09e2f20c88f7de57d49144d13a856b05e8b66220ec9a", + "local_sha256": "f5550702de4ebf52626a09e2f20c88f7de57d49144d13a856b05e8b66220ec9a" + }, + { + "path": "Sources/Textual/Internal/Attachment/ImageAttachment.swift", + "upstream_sha256": "532b9225d217de63aaf2382b3d3c65c98b13752547a742cefb017924ffee772b", + "local_sha256": "532b9225d217de63aaf2382b3d3c65c98b13752547a742cefb017924ffee772b" + }, + { + "path": "Sources/Textual/Internal/Attachment/ImageResourceAttachment.swift", + "upstream_sha256": "4895c0ef2768aee2185ccf83bcda914bb90f7aa0f02033dc2ce5f71de34ef0b0", + "local_sha256": "4895c0ef2768aee2185ccf83bcda914bb90f7aa0f02033dc2ce5f71de34ef0b0" + }, + { + "path": "Sources/Textual/Internal/Attachment/MathAttachment.swift", + "upstream_sha256": "e1ca7f06ea433c13aca611a678e184d5868e8377c9efe2c335bbdc5a8eeb6fe2", + "local_sha256": "e1ca7f06ea433c13aca611a678e184d5868e8377c9efe2c335bbdc5a8eeb6fe2" + }, + { + "path": "Sources/Textual/Internal/Attachment/WithAttachments.swift", + "upstream_sha256": "37fe05776b16451763e157f9ef0ff2aa43b036b44e0924e7e6b80f934734ea52", + "local_sha256": "37fe05776b16451763e157f9ef0ff2aa43b036b44e0924e7e6b80f934734ea52" + }, + { + "path": "Sources/Textual/Internal/Font/AnyFontProvider+Font.swift", + "upstream_sha256": "45da19a1b5a783aa1772432b3974f70833358e40dce28b692ec113f6b8b28c07", + "local_sha256": "45da19a1b5a783aa1772432b3974f70833358e40dce28b692ec113f6b8b28c07" + }, + { + "path": "Sources/Textual/Internal/Font/AnyFontProvider.swift", + "upstream_sha256": "5241b7492346d42b216232a6b8b0f9ee5204e0af12708a88d96fd8bcab8d6e71", + "local_sha256": "5241b7492346d42b216232a6b8b0f9ee5204e0af12708a88d96fd8bcab8d6e71" + }, + { + "path": "Sources/Textual/Internal/Font/FontModifier.swift", + "upstream_sha256": "a7abfc284d4d6c1b3944881602506c4c710af37173eaedb0fe3d1c4f5a096ca0", + "local_sha256": "a7abfc284d4d6c1b3944881602506c4c710af37173eaedb0fe3d1c4f5a096ca0" + }, + { + "path": "Sources/Textual/Internal/Font/FontModifierProvider.swift", + "upstream_sha256": "5904148b027576d96e5efa98d4a2fec0aa40539b146cc07062d380f68f9969eb", + "local_sha256": "5904148b027576d96e5efa98d4a2fec0aa40539b146cc07062d380f68f9969eb" + }, + { + "path": "Sources/Textual/Internal/Font/FontProvider.swift", + "upstream_sha256": "749d87bdc7a4552d50b808a6c54d44ebf790536ac06818b1d65e46ccb8e9a33f", + "local_sha256": "749d87bdc7a4552d50b808a6c54d44ebf790536ac06818b1d65e46ccb8e9a33f" + }, + { + "path": "Sources/Textual/Internal/Font/FontScaleModifier.swift", + "upstream_sha256": "4bcce30e0b6cc2a791c30024e038007f4304848c51058e00f6f98edec0da46e5", + "local_sha256": "4bcce30e0b6cc2a791c30024e038007f4304848c51058e00f6f98edec0da46e5" + }, + { + "path": "Sources/Textual/Internal/Font/NamedFontProvider.swift", + "upstream_sha256": "0f30ee6d73afdbb3dd6b53bdb0052a7193e4a818ec7622613969604fea7e14d4", + "local_sha256": "0f30ee6d73afdbb3dd6b53bdb0052a7193e4a818ec7622613969604fea7e14d4" + }, + { + "path": "Sources/Textual/Internal/Font/PlatformFont.swift", + "upstream_sha256": "10d11638f072b26b9e623f2fd9f69d1c7731de8806eb771ee9a02a443167aa94", + "local_sha256": "10d11638f072b26b9e623f2fd9f69d1c7731de8806eb771ee9a02a443167aa94" + }, + { + "path": "Sources/Textual/Internal/Font/PlatformFontProvider.swift", + "upstream_sha256": "154e3782e32fb61ecf19659e87f4d694e8ca19779044dd4a44e6365a8e42859d", + "local_sha256": "154e3782e32fb61ecf19659e87f4d694e8ca19779044dd4a44e6365a8e42859d" + }, + { + "path": "Sources/Textual/Internal/Font/StaticFontModifierProvider.swift", + "upstream_sha256": "ade427ec0a6200013e3dd5458776b077db72fc9fb154962a92de70132ff26548", + "local_sha256": "ade427ec0a6200013e3dd5458776b077db72fc9fb154962a92de70132ff26548" + }, + { + "path": "Sources/Textual/Internal/Font/SystemFontProvider.swift", + "upstream_sha256": "754cb11edfe5c428d674c8b1ceed14f8356a36d2256f0c06eabfde6df558fdb6", + "local_sha256": "754cb11edfe5c428d674c8b1ceed14f8356a36d2256f0c06eabfde6df558fdb6" + }, + { + "path": "Sources/Textual/Internal/Font/TextStyleFontProvider.swift", + "upstream_sha256": "774c4bfd9d1aac86d4ee2ea48f370d39473d67e3e0dea332c5e8342e00994587", + "local_sha256": "774c4bfd9d1aac86d4ee2ea48f370d39473d67e3e0dea332c5e8342e00994587" + }, + { + "path": "Sources/Textual/Internal/Font/WithFontScaledValue.swift", + "upstream_sha256": "0ac4d6e7af1d6586c7959847f60ce79657e626234db50909e94ce7d994b60f07", + "local_sha256": "0ac4d6e7af1d6586c7959847f60ce79657e626234db50909e94ce7d994b60f07" + }, + { + "path": "Sources/Textual/Internal/Formatting/Formatter+HTML.swift", + "upstream_sha256": "450f36b58b128b3d98eab881fc500c101785034b2e0ce0726ccb2bdea615ed36", + "local_sha256": "6ee4b93a0d7edc6c3dabdf2dcec4d405128e4f056c7a8014f9493895e5f1f758" + }, + { + "path": "Sources/Textual/Internal/Formatting/Formatter+PlainText.swift", + "upstream_sha256": "260ef1eb7fd03e09902189343e67c1f7ff9e0936639494443093517ac36e154f", + "local_sha256": "a31a393ac6bad145b3561b1aef9ee896e782225f8a986642bd61e281286c6419" + }, + { + "path": "Sources/Textual/Internal/Formatting/Formatter.swift", + "upstream_sha256": "e961ea1950ffc0b72bd68ceb8e6005e09008efdc31b1a91fb325e5bab967c8db", + "local_sha256": "e961ea1950ffc0b72bd68ceb8e6005e09008efdc31b1a91fb325e5bab967c8db" + }, + { + "path": "Sources/Textual/Internal/Formatting/TransferableText.swift", + "upstream_sha256": "f9ae0bfd86e687966b13303900ea379e8e2e5af52de2ea3f349dc8b832ab7d29", + "local_sha256": "f9ae0bfd86e687966b13303900ea379e8e2e5af52de2ea3f349dc8b832ab7d29" + }, + { + "path": "Sources/Textual/Internal/Helpers/Array.swift", + "upstream_sha256": "c24b13c87db5265ae6b5a4fac814eeb4fb9bea07220480897a771ca6f306f3f8", + "local_sha256": "c24b13c87db5265ae6b5a4fac814eeb4fb9bea07220480897a771ca6f306f3f8" + }, + { + "path": "Sources/Textual/Internal/Helpers/AttributedString.swift", + "upstream_sha256": "b5de6d87181ce6335545c06abf914a558e1c3dc6fb10b1a0d568081740e327e7", + "local_sha256": "b5de6d87181ce6335545c06abf914a558e1c3dc6fb10b1a0d568081740e327e7" + }, + { + "path": "Sources/Textual/Internal/Helpers/Box.swift", + "upstream_sha256": "e8f600756e01403fbaf59e42d2732ff55c74cf6f6a4c172df5b78aecbaad8535", + "local_sha256": "e8f600756e01403fbaf59e42d2732ff55c74cf6f6a4c172df5b78aecbaad8535" + }, + { + "path": "Sources/Textual/Internal/Helpers/Bundle.swift", + "upstream_sha256": "72c66333cb1456519d659d5e1db007ce9e6dfc75f8b3efe4c77247b6a0e06847", + "local_sha256": "72c66333cb1456519d659d5e1db007ce9e6dfc75f8b3efe4c77247b6a0e06847" + }, + { + "path": "Sources/Textual/Internal/Helpers/CGImage+PNGData.swift", + "upstream_sha256": "40cd24b109604d339d3fefd5f24aa08f80875875319ef7b27a706e839a054562", + "local_sha256": "40cd24b109604d339d3fefd5f24aa08f80875875319ef7b27a706e839a054562" + }, + { + "path": "Sources/Textual/Internal/Helpers/Logger.swift", + "upstream_sha256": "e98419a3566e5464158182d2adac1b0904b5b9c30788c2d3fd1529f9a5a7ea9f", + "local_sha256": "e98419a3566e5464158182d2adac1b0904b5b9c30788c2d3fd1529f9a5a7ea9f" + }, + { + "path": "Sources/Textual/Internal/Helpers/PlatformImage.swift", + "upstream_sha256": "65089a11c9b4da8c7bcc9564f55605f59f89ec4ac4543330ed25f1d2e17a0a76", + "local_sha256": "65089a11c9b4da8c7bcc9564f55605f59f89ec4ac4543330ed25f1d2e17a0a76" + }, + { + "path": "Sources/Textual/Internal/Helpers/Range.swift", + "upstream_sha256": "3df401190d63e6fd9fe1ce14d0bd7f2c759c9d2d651373533432b5577f56a6ed", + "local_sha256": "3df401190d63e6fd9fe1ce14d0bd7f2c759c9d2d651373533432b5577f56a6ed" + }, + { + "path": "Sources/Textual/Internal/Helpers/Tuple.swift", + "upstream_sha256": "b4fa6bafd57db837c61023388faeae2c8e6a2b536b65fa7fa6ced202547cf536", + "local_sha256": "b4fa6bafd57db837c61023388faeae2c8e6a2b536b65fa7fa6ced202547cf536" + }, + { + "path": "Sources/Textual/Internal/Helpers/VerticalAlignment+FirstTextCenter.swift", + "upstream_sha256": "3804605ee3a429c379f8d1c41958e6f7c9a2ab6bb54000c5fec92edcc1c478b1", + "local_sha256": "3804605ee3a429c379f8d1c41958e6f7c9a2ab6bb54000c5fec92edcc1c478b1" + }, + { + "path": "Sources/Textual/Internal/Highlighter/CodeTokenizer.swift", + "upstream_sha256": "43e3bfd472ca2cd8b14f71c5ef65829528ce55b9857c499b38d8aaa269084e9e", + "local_sha256": "43e3bfd472ca2cd8b14f71c5ef65829528ce55b9857c499b38d8aaa269084e9e" + }, + { + "path": "Sources/Textual/Internal/Highlighter/HighlightedTextFragment.swift", + "upstream_sha256": "0b586c394905d1de8b0e7149506df915d6cd3da038abaae16a2b8777d7fcfdd9", + "local_sha256": "0b586c394905d1de8b0e7149506df915d6cd3da038abaae16a2b8777d7fcfdd9" + }, + { + "path": "Sources/Textual/Internal/Highlighter/Prism/prism-bundle.js", + "upstream_sha256": "22b70192dc9d73377b5b8fe2c85655c7e3218afd37b65db638b4e168c4bacbf5", + "local_sha256": "22b70192dc9d73377b5b8fe2c85655c7e3218afd37b65db638b4e168c4bacbf5" + }, + { + "path": "Sources/Textual/Internal/Image/Image.swift", + "upstream_sha256": "faed7ccd5c534072beead46c498c000023e801ce40be74510a367810599fc756", + "local_sha256": "faed7ccd5c534072beead46c498c000023e801ce40be74510a367810599fc756" + }, + { + "path": "Sources/Textual/Internal/Image/ImageLoader.swift", + "upstream_sha256": "113383f59f40cc1e06a15dc3b80fcb8a94054c959cd97995c794983dce1cf964", + "local_sha256": "113383f59f40cc1e06a15dc3b80fcb8a94054c959cd97995c794983dce1cf964" + }, + { + "path": "Sources/Textual/Internal/Image/ImageView.swift", + "upstream_sha256": "3061595d941f7d01e3b80eea92ea66f0d972c3d9d2587cbd454dde9e942c7086", + "local_sha256": "3061595d941f7d01e3b80eea92ea66f0d972c3d9d2587cbd454dde9e942c7086" + }, + { + "path": "Sources/Textual/Internal/InlineText/WithInlineStyle.swift", + "upstream_sha256": "838c1831eda252291266ddc4542ddb1876385c132d45b5fa61bb29e93043e255", + "local_sha256": "838c1831eda252291266ddc4542ddb1876385c132d45b5fa61bb29e93043e255" + }, + { + "path": "Sources/Textual/Internal/MarkdownParser/Emoji+Preview.swift", + "upstream_sha256": "bf9ec77220552cfb031e4e00528fffbabad525eb65f05f0fe2914cfe46e6ca84", + "local_sha256": "bf9ec77220552cfb031e4e00528fffbabad525eb65f05f0fe2914cfe46e6ca84" + }, + { + "path": "Sources/Textual/Internal/MarkdownParser/PatternProcessor.swift", + "upstream_sha256": "48148710614283bf908745bb937cf604b15ea404442a045dea83e4f6097a3a49", + "local_sha256": "48148710614283bf908745bb937cf604b15ea404442a045dea83e4f6097a3a49" + }, + { + "path": "Sources/Textual/Internal/MarkdownParser/PatternTokenizer.swift", + "upstream_sha256": "ca3e811a6fbd2723a17714ce03ad7d56df52d7f213a1188e5220f6d80f772c30", + "local_sha256": "ca3e811a6fbd2723a17714ce03ad7d56df52d7f213a1188e5220f6d80f772c30" + }, + { + "path": "Sources/Textual/Internal/StructuredText/BlockContent.swift", + "upstream_sha256": "5dba454c122e17091dfa89797f2554b6b13082f5d3a72209adfcba2e36820c84", + "local_sha256": "5dba454c122e17091dfa89797f2554b6b13082f5d3a72209adfcba2e36820c84" + }, + { + "path": "Sources/Textual/Internal/StructuredText/BlockQuote.swift", + "upstream_sha256": "a90871e3a65d6448e2e7ade50d71c92057311ccf81d8d4fa0a3e6f9bcb75259b", + "local_sha256": "a90871e3a65d6448e2e7ade50d71c92057311ccf81d8d4fa0a3e6f9bcb75259b" + }, + { + "path": "Sources/Textual/Internal/StructuredText/BlockSpacingKey.swift", + "upstream_sha256": "7fe5fb1f17da8dec9123bd3c4e52ae2613e9fdb4f757d2b77d848123c64b53a3", + "local_sha256": "7fe5fb1f17da8dec9123bd3c4e52ae2613e9fdb4f757d2b77d848123c64b53a3" + }, + { + "path": "Sources/Textual/Internal/StructuredText/BlockVStack.swift", + "upstream_sha256": "3c85ba6073a6b0ec57c1d468cce0d37fa0a3cc5f53217bb436e2d32bda6e1daf", + "local_sha256": "3c85ba6073a6b0ec57c1d468cce0d37fa0a3cc5f53217bb436e2d32bda6e1daf" + }, + { + "path": "Sources/Textual/Internal/StructuredText/CodeBlock.swift", + "upstream_sha256": "db2a780de3bb55e007c60b2f7e7de62b291e30b6bb8e3df83cc43530ea617608", + "local_sha256": "db2a780de3bb55e007c60b2f7e7de62b291e30b6bb8e3df83cc43530ea617608" + }, + { + "path": "Sources/Textual/Internal/StructuredText/Heading.swift", + "upstream_sha256": "7b7626253700f565b7e11632ddfba74bd875d205abb1c1c7d9c1bb789a6c5ef0", + "local_sha256": "7b7626253700f565b7e11632ddfba74bd875d205abb1c1c7d9c1bb789a6c5ef0" + }, + { + "path": "Sources/Textual/Internal/StructuredText/ListEnvironment.swift", + "upstream_sha256": "4ecd77e707be108c6c9771894bd34d517605b704d7bc8f7a97406a2387bcf5ba", + "local_sha256": "4ecd77e707be108c6c9771894bd34d517605b704d7bc8f7a97406a2387bcf5ba" + }, + { + "path": "Sources/Textual/Internal/StructuredText/MathBlock.swift", + "upstream_sha256": "001aecf51cea87c342440a1f7c12dd83c30c30687fccf46b8781d50c4ce9548b", + "local_sha256": "001aecf51cea87c342440a1f7c12dd83c30c30687fccf46b8781d50c4ce9548b" + }, + { + "path": "Sources/Textual/Internal/StructuredText/MathCodeBlock.swift", + "upstream_sha256": "8ae1012b306b36d308adabbf513f364bdaedfc3919473b50ed51ed0b5f7b1ee5", + "local_sha256": "8ae1012b306b36d308adabbf513f364bdaedfc3919473b50ed51ed0b5f7b1ee5" + }, + { + "path": "Sources/Textual/Internal/StructuredText/OrderedList.swift", + "upstream_sha256": "d96b79cbc06a1a1d8fb9feec202ae85916e78c9b8b8e789c2ec4896e4943a2de", + "local_sha256": "d96b79cbc06a1a1d8fb9feec202ae85916e78c9b8b8e789c2ec4896e4943a2de" + }, + { + "path": "Sources/Textual/Internal/StructuredText/Paragraph.swift", + "upstream_sha256": "8b9d98466a95adc176e98e137a8195cb7a50e653dd1112a7d3dea06b9f412947", + "local_sha256": "8b9d98466a95adc176e98e137a8195cb7a50e653dd1112a7d3dea06b9f412947" + }, + { + "path": "Sources/Textual/Internal/StructuredText/Style/OverflowFrameKey.swift", + "upstream_sha256": "d2b2e948e6ab89235ea45a2bbf390f5baa19955b512e0500167c932f1d24b8c6", + "local_sha256": "d2b2e948e6ab89235ea45a2bbf390f5baa19955b512e0500167c932f1d24b8c6" + }, + { + "path": "Sources/Textual/Internal/StructuredText/Style/ResolvedBlockQuoteStyle.swift", + "upstream_sha256": "ce5c5873bf391227d470ecc812f683170b5e8ff5d51ec5c0fa6d6c22818ba389", + "local_sha256": "ce5c5873bf391227d470ecc812f683170b5e8ff5d51ec5c0fa6d6c22818ba389" + }, + { + "path": "Sources/Textual/Internal/StructuredText/Style/ResolvedCodeBlockStyle.swift", + "upstream_sha256": "925fe99ae4a0763605da0469f73c668d2fbf5168b78a005c96e72d66ac274bf3", + "local_sha256": "925fe99ae4a0763605da0469f73c668d2fbf5168b78a005c96e72d66ac274bf3" + }, + { + "path": "Sources/Textual/Internal/StructuredText/Style/ResolvedHeadingStyle.swift", + "upstream_sha256": "c14894b099f9d6ad40c5aabcbfed92f1753db0324fbb74a123af324c79e87f33", + "local_sha256": "c14894b099f9d6ad40c5aabcbfed92f1753db0324fbb74a123af324c79e87f33" + }, + { + "path": "Sources/Textual/Internal/StructuredText/Style/ResolvedListItemStyle.swift", + "upstream_sha256": "abe7a913aedb934bd6b25ae878fa2a22c97725bcad119d39d0ec8e299224efef", + "local_sha256": "abe7a913aedb934bd6b25ae878fa2a22c97725bcad119d39d0ec8e299224efef" + }, + { + "path": "Sources/Textual/Internal/StructuredText/Style/ResolvedOrderedListMarker.swift", + "upstream_sha256": "3d90f120813e4cb94f4356cb54a10b6e1745aad19c36d4c2157e53a820a9e979", + "local_sha256": "3d90f120813e4cb94f4356cb54a10b6e1745aad19c36d4c2157e53a820a9e979" + }, + { + "path": "Sources/Textual/Internal/StructuredText/Style/ResolvedParagraphStyle.swift", + "upstream_sha256": "c7bbd7944ef0b2a9455d757c55d9ffccac770259307a3d2c650a076d4531b8e4", + "local_sha256": "c7bbd7944ef0b2a9455d757c55d9ffccac770259307a3d2c650a076d4531b8e4" + }, + { + "path": "Sources/Textual/Internal/StructuredText/Style/ResolvedTableCellStyle.swift", + "upstream_sha256": "ad7027af5438d859f66a2cb57f72632d89404871e4c20d0aa55580bb3495e11a", + "local_sha256": "ad7027af5438d859f66a2cb57f72632d89404871e4c20d0aa55580bb3495e11a" + }, + { + "path": "Sources/Textual/Internal/StructuredText/Style/ResolvedTableStyle.swift", + "upstream_sha256": "bb73bb48c2a45970559b1d0dea02d6b1722c0e5568e2bc49ffeffc883038e3ab", + "local_sha256": "bb73bb48c2a45970559b1d0dea02d6b1722c0e5568e2bc49ffeffc883038e3ab" + }, + { + "path": "Sources/Textual/Internal/StructuredText/Style/ResolvedThematicBreakStyle.swift", + "upstream_sha256": "c863bb4bf7d080b5c74ae06bc044291edb6d2c57fbf515c75731b4c439c11e67", + "local_sha256": "c863bb4bf7d080b5c74ae06bc044291edb6d2c57fbf515c75731b4c439c11e67" + }, + { + "path": "Sources/Textual/Internal/StructuredText/Style/ResolvedUnorderedListMarker.swift", + "upstream_sha256": "2ce8e477344f7e0f82888fcb11366cc62581147cd015773b6100ee95a54692f4", + "local_sha256": "2ce8e477344f7e0f82888fcb11366cc62581147cd015773b6100ee95a54692f4" + }, + { + "path": "Sources/Textual/Internal/StructuredText/Table.swift", + "upstream_sha256": "e5a4089d27e0f473a6409a54e3f4594cae0797cb3190457ab408593202b8c055", + "local_sha256": "e5a4089d27e0f473a6409a54e3f4594cae0797cb3190457ab408593202b8c055" + }, + { + "path": "Sources/Textual/Internal/StructuredText/TableCell.swift", + "upstream_sha256": "823afb0cfe7bb127422b88b56796a34284137b5421907ebd70be129d7ee0c60d", + "local_sha256": "823afb0cfe7bb127422b88b56796a34284137b5421907ebd70be129d7ee0c60d" + }, + { + "path": "Sources/Textual/Internal/StructuredText/ThematicBreak.swift", + "upstream_sha256": "14e0766c077764989326da8e230bdc912ee3c574e054d91fce0e24cef9364fb4", + "local_sha256": "14e0766c077764989326da8e230bdc912ee3c574e054d91fce0e24cef9364fb4" + }, + { + "path": "Sources/Textual/Internal/StructuredText/UnorderedList.swift", + "upstream_sha256": "95a9ab2479c555a0c5d84747ba1fd9ed0b8819d1cb93bb0b1607c14fccbec3cc", + "local_sha256": "95a9ab2479c555a0c5d84747ba1fd9ed0b8819d1cb93bb0b1607c14fccbec3cc" + }, + { + "path": "Sources/Textual/Internal/TextFragment/TextBuilder.swift", + "upstream_sha256": "8bc965e2ec5c1146ef9857114c24cb32826c9e0e836dc5c60a28157f1d3b027e", + "local_sha256": "8bc965e2ec5c1146ef9857114c24cb32826c9e0e836dc5c60a28157f1d3b027e" + }, + { + "path": "Sources/Textual/Internal/TextFragment/TextFragment.swift", + "upstream_sha256": "839bae276e3451107bf4d28f9f29a48ea09ea202a54c44a86e708243ec03a6b8", + "local_sha256": "839bae276e3451107bf4d28f9f29a48ea09ea202a54c44a86e708243ec03a6b8" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/AppKit/AppKitTextInteractionOverlay.swift", + "upstream_sha256": "02dff5b4f48e2d25a5487e3ae094a6acbe166b97a38217f1f94c3468d562c903", + "local_sha256": "02dff5b4f48e2d25a5487e3ae094a6acbe166b97a38217f1f94c3468d562c903" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/AppKit/AppKitTextSelectionInteraction.swift", + "upstream_sha256": "45d79bfd0746790abf59f806d062b5f69e53a16d50a70aad785139a30d006c05", + "local_sha256": "45d79bfd0746790abf59f806d062b5f69e53a16d50a70aad785139a30d006c05" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/AppKit/AppKitTextSelectionView.swift", + "upstream_sha256": "d2826dd3a57f3faf4cd258d33bd0314e4b90371c84c44b147376b4cee8f62531", + "local_sha256": "d2826dd3a57f3faf4cd258d33bd0314e4b90371c84c44b147376b4cee8f62531" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/AppKit/NSTextInteractionView.swift", + "upstream_sha256": "57ed95840c4467439e50d55e3fcd7634b751858700e400eb40e359029d79d4a1", + "local_sha256": "57ed95840c4467439e50d55e3fcd7634b751858700e400eb40e359029d79d4a1" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/IndexPath+TextPosition.swift", + "upstream_sha256": "a8bf8bb772d8f9ecfc4e29664d8dd2e26e47951f7f4e29d1735b16ecb10ce77b", + "local_sha256": "a8bf8bb772d8f9ecfc4e29664d8dd2e26e47951f7f4e29d1735b16ecb10ce77b" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/LayoutDirection+Locale.swift", + "upstream_sha256": "ea982d2e7a1c4651cc6770861a1816f6be198aca5bb2ddeea56bf9b3a1d60069", + "local_sha256": "ea982d2e7a1c4651cc6770861a1816f6be198aca5bb2ddeea56bf9b3a1d60069" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/LinkAttribute.swift", + "upstream_sha256": "adfca7dbea1069919a2fddc20ab3e86fd91c855ef5523206ebd0f7bd86d8efe2", + "local_sha256": "adfca7dbea1069919a2fddc20ab3e86fd91c855ef5523206ebd0f7bd86d8efe2" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextLayout/EmptyTextLayoutCollection.swift", + "upstream_sha256": "98a60b42ba7b5d5925693ea1c96e84cbf8aad1b0e83815d6f50637ccafdd14e7", + "local_sha256": "98a60b42ba7b5d5925693ea1c96e84cbf8aad1b0e83815d6f50637ccafdd14e7" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextLayout/Layout+Internals.swift", + "upstream_sha256": "f32d1efb74e6116801785c4a12219209f23171be8e3299f353afa56868f1f875", + "local_sha256": "f32d1efb74e6116801785c4a12219209f23171be8e3299f353afa56868f1f875" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextLayout/LiveTextLayoutCollection.swift", + "upstream_sha256": "d8d1b34f62a64c9ed12bfaa017e24d8661be65d04833f4ac947b53691dd5a38b", + "local_sha256": "d8d1b34f62a64c9ed12bfaa017e24d8661be65d04833f4ac947b53691dd5a38b" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+AttributedStringAccess.swift", + "upstream_sha256": "ecf59989161809e177c64987ce64e17400f769c560a4d6125dba6c538f85bf05", + "local_sha256": "ecf59989161809e177c64987ce64e17400f769c560a4d6125dba6c538f85bf05" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+Geometry.swift", + "upstream_sha256": "d751b97d8f149839ed68d0f1e7c2684845c46b40f5d4f5162e123be8f88da0bf", + "local_sha256": "d751b97d8f149839ed68d0f1e7c2684845c46b40f5d4f5162e123be8f88da0bf" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+Positioning.swift", + "upstream_sha256": "80f0c1d10a9eb597018e1a24dde203880d3f8ffdbe2628a21338e4c0e56576d3", + "local_sha256": "80f0c1d10a9eb597018e1a24dde203880d3f8ffdbe2628a21338e4c0e56576d3" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+Ranges.swift", + "upstream_sha256": "2859b1b13d1d2d91e84b45bbc74b18f4bb2d01db82fb69414b4090decbd803ed", + "local_sha256": "2859b1b13d1d2d91e84b45bbc74b18f4bb2d01db82fb69414b4090decbd803ed" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+RunSliceTraversal.swift", + "upstream_sha256": "ce19158ba12786f5acc45b10456e8f7b52a0e444c306cc5d82f041aa85880f46", + "local_sha256": "ce19158ba12786f5acc45b10456e8f7b52a0e444c306cc5d82f041aa85880f46" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+SelectionRects.swift", + "upstream_sha256": "e9b491641be35a3697d948a62f3917ed5343fae44cc5250c695db238c12436c1", + "local_sha256": "e9b491641be35a3697d948a62f3917ed5343fae44cc5250c695db238c12436c1" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection.swift", + "upstream_sha256": "f0e150fb0a6c6be19c894aeeab3ab6d5737ba59e939b8b7146815fb19229fc4a", + "local_sha256": "f0e150fb0a6c6be19c894aeeab3ab6d5737ba59e939b8b7146815fb19229fc4a" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextLayout/View+TextLayoutCollection.swift", + "upstream_sha256": "6a41b1b861c71ed9529274b4ced3279b831b12ebc35cbbba539a5056b37589e9", + "local_sha256": "6a41b1b861c71ed9529274b4ced3279b831b12ebc35cbbba539a5056b37589e9" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextLinkInteraction.swift", + "upstream_sha256": "69b03439a898385094cd7c332cc2b0e709d5dec94e6cec610c2903bb918669cf", + "local_sha256": "69b03439a898385094cd7c332cc2b0e709d5dec94e6cec610c2903bb918669cf" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextPosition.swift", + "upstream_sha256": "be2d6c351dadccaee57c7ffeae4f8806ba3d8bc52cfd8511825a27e79d5b4df0", + "local_sha256": "be2d6c351dadccaee57c7ffeae4f8806ba3d8bc52cfd8511825a27e79d5b4df0" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextRange.swift", + "upstream_sha256": "1e120b1709260b3ff17b40076973dde635660b27b85bc529114d3bfdbb49a6a2", + "local_sha256": "1e120b1709260b3ff17b40076973dde635660b27b85bc529114d3bfdbb49a6a2" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextSelectionBackground.swift", + "upstream_sha256": "d4f014ad759f16f937667ebf3caf8f8f7cac290b057d7f0d3ead1c26eedc9f26", + "local_sha256": "d4f014ad759f16f937667ebf3caf8f8f7cac290b057d7f0d3ead1c26eedc9f26" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextSelectionCoordinator.swift", + "upstream_sha256": "1c3f8393295cc67c0f3a1aa617b9045703e2205a5332fb3f9dfbedd3e16020eb", + "local_sha256": "1c3f8393295cc67c0f3a1aa617b9045703e2205a5332fb3f9dfbedd3e16020eb" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextSelectionInteraction.swift", + "upstream_sha256": "cb31920a50fbcfb23c3874bfbc1629587f86ae66f3c1f35947e058a49f4338a4", + "local_sha256": "cb31920a50fbcfb23c3874bfbc1629587f86ae66f3c1f35947e058a49f4338a4" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextSelectionModel.swift", + "upstream_sha256": "2e95d6f1cf6af7dd9cc79a9742a8720e59e3baa5cac0cf98713c0b8c35c86b37", + "local_sha256": "2e95d6f1cf6af7dd9cc79a9742a8720e59e3baa5cac0cf98713c0b8c35c86b37" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextSelectionRect.swift", + "upstream_sha256": "29a2b2c3de16e56dcd4a255c9dda2d3c16e6af331b7be7d4552ccd45db68d04f", + "local_sha256": "29a2b2c3de16e56dcd4a255c9dda2d3c16e6af331b7be7d4552ccd45db68d04f" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/UIKit/TextActivityItemSource.swift", + "upstream_sha256": "0a7ba7f5ced58d87decb1bd1d623ec4ab6cf6745f1b8b87dff45f7ee33295a29", + "local_sha256": "0a7ba7f5ced58d87decb1bd1d623ec4ab6cf6745f1b8b87dff45f7ee33295a29" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/UIKit/TextPositionBox.swift", + "upstream_sha256": "758f17ab92714617e153d1275957e92bf2bac795bdb1e40023a1e42cfc059b1e", + "local_sha256": "758f17ab92714617e153d1275957e92bf2bac795bdb1e40023a1e42cfc059b1e" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/UIKit/TextRangeBox.swift", + "upstream_sha256": "83612fee5c0eddb89fed82a0ec6c99e00ba9101902c1a74bbd4d1330b82de8ed", + "local_sha256": "83612fee5c0eddb89fed82a0ec6c99e00ba9101902c1a74bbd4d1330b82de8ed" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/UIKit/TextSelectionRectBox.swift", + "upstream_sha256": "699d4e9e3f5fdca9999e8cf3bb1f077b3a81646e0fc9e276547fe3bb965e7612", + "local_sha256": "699d4e9e3f5fdca9999e8cf3bb1f077b3a81646e0fc9e276547fe3bb965e7612" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/UIKit/UIKitTextInteractionOverlay.swift", + "upstream_sha256": "5b169e9589b6f78d851c4be2870ad674de446ab1d5fcebd365f4efb4b03ac45f", + "local_sha256": "5b169e9589b6f78d851c4be2870ad674de446ab1d5fcebd365f4efb4b03ac45f" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/UIKit/UIKitTextSelectionInteraction.swift", + "upstream_sha256": "db2ac2603df19f70bc170fcb1706d76c7f47563a8045eefaadf035e9af6c2b05", + "local_sha256": "db2ac2603df19f70bc170fcb1706d76c7f47563a8045eefaadf035e9af6c2b05" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/UIKit/UITextInteractionView+UITextInput.swift", + "upstream_sha256": "ae297d82eda5becc0a64a1efcfbd99702e7c3163d06486b178ac9b5c6532945e", + "local_sha256": "ae297d82eda5becc0a64a1efcfbd99702e7c3163d06486b178ac9b5c6532945e" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/UIKit/UITextInteractionView+UITextInputTokenizer.swift", + "upstream_sha256": "888826d312cc25c42a8b85a2491c64d6f429887972556162fb07c397dcaa071c", + "local_sha256": "888826d312cc25c42a8b85a2491c64d6f429887972556162fb07c397dcaa071c" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/UIKit/UITextInteractionView.swift", + "upstream_sha256": "7ec6c8f8f6ec8aa0b7cb444c2e3bd4083364a15c9da6ddbc0e7eb289535cc20e", + "local_sha256": "ff74afe5771e3a867af79663b15b52baf3262c4b9cc9a03df243829253f9c2f0" + }, + { + "path": "Sources/Textual/MarkdownParser/AttributedStringMarkdownParser.swift", + "upstream_sha256": "d470a3d8c59f0d321eddeba9e9c005dc4cb861c9e271d5c8555243a1abd77c29", + "local_sha256": "d470a3d8c59f0d321eddeba9e9c005dc4cb861c9e271d5c8555243a1abd77c29" + }, + { + "path": "Sources/Textual/MarkdownParser/Emoji.swift", + "upstream_sha256": "618a38612e907ca0615be4e397f081623c4ddb330ba3342ca51f728afbf02549", + "local_sha256": "618a38612e907ca0615be4e397f081623c4ddb330ba3342ca51f728afbf02549" + }, + { + "path": "Sources/Textual/MarkdownParser/SyntaxExtension.swift", + "upstream_sha256": "7056bf85712c1801fa0606de4bc842634f645055b65d09d52345570578a99a6a", + "local_sha256": "7056bf85712c1801fa0606de4bc842634f645055b65d09d52345570578a99a6a" + }, + { + "path": "Sources/Textual/MarkdownParser/TextualAttributes.swift", + "upstream_sha256": "9e13f5b291aa800135c81f75ad7989b2fb947fa3064c40475f412582e5fbde09", + "local_sha256": "9e13f5b291aa800135c81f75ad7989b2fb947fa3064c40475f412582e5fbde09" + }, + { + "path": "Sources/Textual/MarkupParser.swift", + "upstream_sha256": "6c3d6b880513cfa86b842588a286a8dadeb9a79c063298421f5c37eed6e5de20", + "local_sha256": "6c3d6b880513cfa86b842588a286a8dadeb9a79c063298421f5c37eed6e5de20" + }, + { + "path": "Sources/Textual/MathProperties.swift", + "upstream_sha256": "0c07b9647838963d3148981d704c7fed3d2caac455d34a8fd37aa4ea0cf581cf", + "local_sha256": "0c07b9647838963d3148981d704c7fed3d2caac455d34a8fd37aa4ea0cf581cf" + }, + { + "path": "Sources/Textual/StructuredText/HighlighterTheme/HighlighterTheme+Default.swift", + "upstream_sha256": "00d5cc56ced06c3efd7fee4091d85dc5e3852788425b9a6f874e9f470b3c2a9c", + "local_sha256": "00d5cc56ced06c3efd7fee4091d85dc5e3852788425b9a6f874e9f470b3c2a9c" + }, + { + "path": "Sources/Textual/StructuredText/HighlighterTheme/HighlighterTheme+Plain.swift", + "upstream_sha256": "e68930f38227780c54551a92657f950a4fccf6e2f33e4babc56339e3c61e54b7", + "local_sha256": "e68930f38227780c54551a92657f950a4fccf6e2f33e4babc56339e3c61e54b7" + }, + { + "path": "Sources/Textual/StructuredText/HighlighterTheme/HighlighterTheme.swift", + "upstream_sha256": "276b3d897611aebce37f9b06373adbe5bde1d66ec99e2f3f7da30bb0cc8c6197", + "local_sha256": "276b3d897611aebce37f9b06373adbe5bde1d66ec99e2f3f7da30bb0cc8c6197" + }, + { + "path": "Sources/Textual/StructuredText/HighlighterTheme/TokenType.swift", + "upstream_sha256": "73d933cf60a7d7cafe4d34ac41e7c81331a82ed883b0cf6236999bbd9a230b9e", + "local_sha256": "73d933cf60a7d7cafe4d34ac41e7c81331a82ed883b0cf6236999bbd9a230b9e" + }, + { + "path": "Sources/Textual/StructuredText/StructuredText.swift", + "upstream_sha256": "72bd3ad4db3ffae696bc95773735de03c537d2467c79c89f4ba0e644783fc2dd", + "local_sha256": "72bd3ad4db3ffae696bc95773735de03c537d2467c79c89f4ba0e644783fc2dd" + }, + { + "path": "Sources/Textual/StructuredText/Style/BlockQuoteStyle.swift", + "upstream_sha256": "b00409732e18b5ceb2f9a349c8235d83ee1ab2f3c2554dd9e44ba9f28da73930", + "local_sha256": "b00409732e18b5ceb2f9a349c8235d83ee1ab2f3c2554dd9e44ba9f28da73930" + }, + { + "path": "Sources/Textual/StructuredText/Style/BlockSpacing.swift", + "upstream_sha256": "a8ea9a161bf19dfa9a50f746914b79e5df50cd8f58061361e6330017f21d1894", + "local_sha256": "a8ea9a161bf19dfa9a50f746914b79e5df50cd8f58061361e6330017f21d1894" + }, + { + "path": "Sources/Textual/StructuredText/Style/BlockStyleConfiguration.swift", + "upstream_sha256": "6bd90d22c28dc4abe2335a07291bea6c8fad8892fc7c8c5844f54270733b1ee8", + "local_sha256": "6bd90d22c28dc4abe2335a07291bea6c8fad8892fc7c8c5844f54270733b1ee8" + }, + { + "path": "Sources/Textual/StructuredText/Style/CodeBlockProxy.swift", + "upstream_sha256": "e4056c60c210c40150cc7fae69423e2352c76cae97cc6df8a71b2b7405716c27", + "local_sha256": "e4056c60c210c40150cc7fae69423e2352c76cae97cc6df8a71b2b7405716c27" + }, + { + "path": "Sources/Textual/StructuredText/Style/CodeBlockStyle.swift", + "upstream_sha256": "50e327aaa7f8b8c91302457f8f0453034f5b3804dabea37f4ed0319ea0817615", + "local_sha256": "50e327aaa7f8b8c91302457f8f0453034f5b3804dabea37f4ed0319ea0817615" + }, + { + "path": "Sources/Textual/StructuredText/Style/Default/DefaultBlockQuoteStyle.swift", + "upstream_sha256": "fc4010488d7e77e55d550c01d73121b86df853ca77b58d53f64fe3e601dab9e5", + "local_sha256": "fc4010488d7e77e55d550c01d73121b86df853ca77b58d53f64fe3e601dab9e5" + }, + { + "path": "Sources/Textual/StructuredText/Style/Default/DefaultCodeBlockStyle.swift", + "upstream_sha256": "cb00fcb603b477bcaa42af6bb8b61443806c76948cf5433a66815351763b8a66", + "local_sha256": "cb00fcb603b477bcaa42af6bb8b61443806c76948cf5433a66815351763b8a66" + }, + { + "path": "Sources/Textual/StructuredText/Style/Default/DefaultHeadingStyle.swift", + "upstream_sha256": "fae638508cc2a15f3503efbb3b7ddb764626cd6ff2605159e091619f4d8decd9", + "local_sha256": "fae638508cc2a15f3503efbb3b7ddb764626cd6ff2605159e091619f4d8decd9" + }, + { + "path": "Sources/Textual/StructuredText/Style/Default/DefaultListItemStyle.swift", + "upstream_sha256": "c62ae85e9e9ae5bdcdf4d62d4d530749cbeb6a84795a594f438c8f8f70d57fd8", + "local_sha256": "c62ae85e9e9ae5bdcdf4d62d4d530749cbeb6a84795a594f438c8f8f70d57fd8" + }, + { + "path": "Sources/Textual/StructuredText/Style/Default/DefaultParagraphStyle.swift", + "upstream_sha256": "bc2dcf7e00fafb2af56d310541f8ce9b87d4891acd5c07e3874766a0a9b5a7e9", + "local_sha256": "bc2dcf7e00fafb2af56d310541f8ce9b87d4891acd5c07e3874766a0a9b5a7e9" + }, + { + "path": "Sources/Textual/StructuredText/Style/Default/DefaultStyle.swift", + "upstream_sha256": "ec9dc56b7569a48dcb69662b4a3a47a552fae808af389f21fbce08ea111aed98", + "local_sha256": "ec9dc56b7569a48dcb69662b4a3a47a552fae808af389f21fbce08ea111aed98" + }, + { + "path": "Sources/Textual/StructuredText/Style/Default/DefaultTableCellStyle.swift", + "upstream_sha256": "6be859d593553a8280853f6910fe9385bc0385143889177d29c5dda56e637df5", + "local_sha256": "6be859d593553a8280853f6910fe9385bc0385143889177d29c5dda56e637df5" + }, + { + "path": "Sources/Textual/StructuredText/Style/Default/DefaultTableStyle.swift", + "upstream_sha256": "6b4badf53b4b5572e8fae3165dc0bae82afe02769de76080737bd4dd8386af8c", + "local_sha256": "6b4badf53b4b5572e8fae3165dc0bae82afe02769de76080737bd4dd8386af8c" + }, + { + "path": "Sources/Textual/StructuredText/Style/Default/DividerThematicBreakStyle.swift", + "upstream_sha256": "06b62e386cbb59248dac490dadb9048cf9b46362d6bbdcef27a5af3514f600b0", + "local_sha256": "06b62e386cbb59248dac490dadb9048cf9b46362d6bbdcef27a5af3514f600b0" + }, + { + "path": "Sources/Textual/StructuredText/Style/Default/DynamicColor+Default.swift", + "upstream_sha256": "47f466de41afab5a3ba4c9ab7a00fc52dce315ab245b256de28f51f6303d8370", + "local_sha256": "47f466de41afab5a3ba4c9ab7a00fc52dce315ab245b256de28f51f6303d8370" + }, + { + "path": "Sources/Textual/StructuredText/Style/Default/OverflowTableStyle.swift", + "upstream_sha256": "2055509570ad72f7fb0c37a1defc388cd7460f057f9bb88df802754d0c6cae63", + "local_sha256": "2055509570ad72f7fb0c37a1defc388cd7460f057f9bb88df802754d0c6cae63" + }, + { + "path": "Sources/Textual/StructuredText/Style/GitHub/DynamicColor+GitHub.swift", + "upstream_sha256": "e4e97aa3bdb4d7440fefbafc4cbd04fd0848d24dfc3ddf3ee214a850927096c3", + "local_sha256": "e4e97aa3bdb4d7440fefbafc4cbd04fd0848d24dfc3ddf3ee214a850927096c3" + }, + { + "path": "Sources/Textual/StructuredText/Style/GitHub/GitHubBlockQuoteStyle.swift", + "upstream_sha256": "5cbd495c8d149365b3fd1b5cee98f5fe4a3a0d3e444deb21df35602ef7809483", + "local_sha256": "5cbd495c8d149365b3fd1b5cee98f5fe4a3a0d3e444deb21df35602ef7809483" + }, + { + "path": "Sources/Textual/StructuredText/Style/GitHub/GitHubCodeBlockStyle.swift", + "upstream_sha256": "b10c6d844518fcaaad4bcbfbfecd73136fd43a4f204b5cf077d51c49679bd6c5", + "local_sha256": "b10c6d844518fcaaad4bcbfbfecd73136fd43a4f204b5cf077d51c49679bd6c5" + }, + { + "path": "Sources/Textual/StructuredText/Style/GitHub/GitHubHeadingStyle.swift", + "upstream_sha256": "047a67e912c52a7810621fb2a3143d54415c94b3c3625a8619f6086daeca630b", + "local_sha256": "047a67e912c52a7810621fb2a3143d54415c94b3c3625a8619f6086daeca630b" + }, + { + "path": "Sources/Textual/StructuredText/Style/GitHub/GitHubParagraphStyle.swift", + "upstream_sha256": "1026396ce0e727ab620e5349d42fe585cd3fe420f4d570b82df08fa7543cef69", + "local_sha256": "1026396ce0e727ab620e5349d42fe585cd3fe420f4d570b82df08fa7543cef69" + }, + { + "path": "Sources/Textual/StructuredText/Style/GitHub/GitHubStyle.swift", + "upstream_sha256": "f2bbc522200fb2c7280be436481c0abf056c116ae4b9a7ebef43b1199f658566", + "local_sha256": "f2bbc522200fb2c7280be436481c0abf056c116ae4b9a7ebef43b1199f658566" + }, + { + "path": "Sources/Textual/StructuredText/Style/GitHub/GitHubTableCellStyle.swift", + "upstream_sha256": "18300fdac59047c2717e8d47c2354383f7ad0d3fb2a53cd53f343fb395ab13ff", + "local_sha256": "18300fdac59047c2717e8d47c2354383f7ad0d3fb2a53cd53f343fb395ab13ff" + }, + { + "path": "Sources/Textual/StructuredText/Style/GitHub/GitHubTableStyle.swift", + "upstream_sha256": "c8e4e8b126573b6046808a32279e0f4778f80afaf355b7e0c56496f460b1db54", + "local_sha256": "c8e4e8b126573b6046808a32279e0f4778f80afaf355b7e0c56496f460b1db54" + }, + { + "path": "Sources/Textual/StructuredText/Style/GitHub/GitHubThematicBreak.swift", + "upstream_sha256": "cbcd93d80b4577935649bf2c051d5f69ba1705902751d6d2661cadde48458679", + "local_sha256": "cbcd93d80b4577935649bf2c051d5f69ba1705902751d6d2661cadde48458679" + }, + { + "path": "Sources/Textual/StructuredText/Style/HeadingStyle.swift", + "upstream_sha256": "aa5ec2278073303aff9eba6eab59f60341298c48656bc62ff7754943b1224635", + "local_sha256": "aa5ec2278073303aff9eba6eab59f60341298c48656bc62ff7754943b1224635" + }, + { + "path": "Sources/Textual/StructuredText/Style/ListItemStyle.swift", + "upstream_sha256": "f62250aebe73172d5b42e5453d28793b37f8c40463726ebc33142ab3aa993a84", + "local_sha256": "f62250aebe73172d5b42e5453d28793b37f8c40463726ebc33142ab3aa993a84" + }, + { + "path": "Sources/Textual/StructuredText/Style/OrderedListMarker.swift", + "upstream_sha256": "ebf3919391896da40d45024cbf7fb45f5569e9ac55b2fdea31aa36ffb96fc0ef", + "local_sha256": "ebf3919391896da40d45024cbf7fb45f5569e9ac55b2fdea31aa36ffb96fc0ef" + }, + { + "path": "Sources/Textual/StructuredText/Style/Overflow.swift", + "upstream_sha256": "ff9b7d6ea701b1613324b6ce5715d176233c23ebcff41a8252885dc9601466a6", + "local_sha256": "b76db1e1943cffc4ab88b76bc2cc3c1cd5a424dd4bc3e1169367d07465e4b196" + }, + { + "path": "Sources/Textual/StructuredText/Style/ParagraphStyle.swift", + "upstream_sha256": "9fb0fa98b7779b2715918ed984d30ee56cc60df86ad5ae16da0d1151759c1fb6", + "local_sha256": "9fb0fa98b7779b2715918ed984d30ee56cc60df86ad5ae16da0d1151759c1fb6" + }, + { + "path": "Sources/Textual/StructuredText/Style/Style.swift", + "upstream_sha256": "e6646ddf07631afb0d25390c29c9373e62ccc049143d08f81779656edc96fa03", + "local_sha256": "e6646ddf07631afb0d25390c29c9373e62ccc049143d08f81779656edc96fa03" + }, + { + "path": "Sources/Textual/StructuredText/Style/TableCellStyle.swift", + "upstream_sha256": "74285756cddb10dcc57f8b7b0b0e30276ed92645bf4d65ae515952810409ab1f", + "local_sha256": "74285756cddb10dcc57f8b7b0b0e30276ed92645bf4d65ae515952810409ab1f" + }, + { + "path": "Sources/Textual/StructuredText/Style/TableLayout.swift", + "upstream_sha256": "8fcf75f3d47bfe8a3a3814afa3fafa8521db2cfb3de01f383b5ec5a08297d4aa", + "local_sha256": "8fcf75f3d47bfe8a3a3814afa3fafa8521db2cfb3de01f383b5ec5a08297d4aa" + }, + { + "path": "Sources/Textual/StructuredText/Style/TableStyle.swift", + "upstream_sha256": "02df543d63caeab909a721957e84379fb702979050fff0c1d406b790282e5533", + "local_sha256": "02df543d63caeab909a721957e84379fb702979050fff0c1d406b790282e5533" + }, + { + "path": "Sources/Textual/StructuredText/Style/ThematicBreakStyle.swift", + "upstream_sha256": "44c45981069ee0c571fe2de8a1ced6373267335c3dacc34b646ebaafdbb1e64e", + "local_sha256": "44c45981069ee0c571fe2de8a1ced6373267335c3dacc34b646ebaafdbb1e64e" + }, + { + "path": "Sources/Textual/StructuredText/Style/UnorderedListMarker.swift", + "upstream_sha256": "e6eb544f63329f6f0ed3a3583644d4879df8672c3f1c78772a3ffc05562e350c", + "local_sha256": "e6eb544f63329f6f0ed3a3583644d4879df8672c3f1c78772a3ffc05562e350c" + }, + { + "path": "Sources/Textual/TextEnvironmentValues.swift", + "upstream_sha256": "529835802d14ad5214b015d066d6d2e840139771508d03f68a54676492bf1763", + "local_sha256": "529835802d14ad5214b015d066d6d2e840139771508d03f68a54676492bf1763" + }, + { + "path": "Sources/Textual/TextProperty/Color/BackgroundColorProperty.swift", + "upstream_sha256": "c2d8d08622672a864c04720b5ad990f704b74c3df60772ecba9a0accf15e93f3", + "local_sha256": "c2d8d08622672a864c04720b5ad990f704b74c3df60772ecba9a0accf15e93f3" + }, + { + "path": "Sources/Textual/TextProperty/Color/ForegroundColorProperty.swift", + "upstream_sha256": "3e42917538bdd2ae911a51b64bf867ca297492f440be3751b97623fa77a0bc12", + "local_sha256": "3e42917538bdd2ae911a51b64bf867ca297492f440be3751b97623fa77a0bc12" + }, + { + "path": "Sources/Textual/TextProperty/Font/BoldProperty.swift", + "upstream_sha256": "a83a6d1cef8c41f26a8933ca9ccbd76cf1fca362e0aca17c1fc81721188d6361", + "local_sha256": "a83a6d1cef8c41f26a8933ca9ccbd76cf1fca362e0aca17c1fc81721188d6361" + }, + { + "path": "Sources/Textual/TextProperty/Font/FontLeadingProperty.swift", + "upstream_sha256": "356aed7420af4a6d650b22d27534498fad4cf125bb1caf5e90af1c84f22b1c7a", + "local_sha256": "356aed7420af4a6d650b22d27534498fad4cf125bb1caf5e90af1c84f22b1c7a" + }, + { + "path": "Sources/Textual/TextProperty/Font/FontProperty.swift", + "upstream_sha256": "8ede8e0a5db2aceecf52ea5a884bcf28717810c2a9c4035dbfadfe155ab2401f", + "local_sha256": "8ede8e0a5db2aceecf52ea5a884bcf28717810c2a9c4035dbfadfe155ab2401f" + }, + { + "path": "Sources/Textual/TextProperty/Font/FontScaleProperty.swift", + "upstream_sha256": "25c9a51ddaf5e23b0bf113fd68cfb2e037498a318619dd5b761ec763ebab6478", + "local_sha256": "25c9a51ddaf5e23b0bf113fd68cfb2e037498a318619dd5b761ec763ebab6478" + }, + { + "path": "Sources/Textual/TextProperty/Font/FontWeightProperty.swift", + "upstream_sha256": "fda98f5176b16ae4562ac38adbfd369b48ffcb7add6b7cc8eb0c95cb1538418c", + "local_sha256": "fda98f5176b16ae4562ac38adbfd369b48ffcb7add6b7cc8eb0c95cb1538418c" + }, + { + "path": "Sources/Textual/TextProperty/Font/FontWidthProperty.swift", + "upstream_sha256": "d055350ce9e28a955418f027a7d981d876c05d24064b1e1b59fee803a2edcd9d", + "local_sha256": "d055350ce9e28a955418f027a7d981d876c05d24064b1e1b59fee803a2edcd9d" + }, + { + "path": "Sources/Textual/TextProperty/Font/ItalicProperty.swift", + "upstream_sha256": "b1e12c2148e841c98a618ba3cc129b07040075d51612dcacf5bc4aa9ed00f4c1", + "local_sha256": "b1e12c2148e841c98a618ba3cc129b07040075d51612dcacf5bc4aa9ed00f4c1" + }, + { + "path": "Sources/Textual/TextProperty/Font/LowercaseSmallCapsProperty.swift", + "upstream_sha256": "13f2ae9b03dafbcd3acf494c86904fef9d74b7bf77c4bf1ca5894d12cc4e3d9d", + "local_sha256": "13f2ae9b03dafbcd3acf494c86904fef9d74b7bf77c4bf1ca5894d12cc4e3d9d" + }, + { + "path": "Sources/Textual/TextProperty/Font/MonospacedDigitProperty.swift", + "upstream_sha256": "c7605627baa98312e183f9a3431e28e9c64b49cd654f14d8e904a06ada50c200", + "local_sha256": "c7605627baa98312e183f9a3431e28e9c64b49cd654f14d8e904a06ada50c200" + }, + { + "path": "Sources/Textual/TextProperty/Font/MonospacedProperty.swift", + "upstream_sha256": "8960c5c5403e12173b47ef9fd425295cbecb3fb848cb35d8f3aacdf9ab62398d", + "local_sha256": "8960c5c5403e12173b47ef9fd425295cbecb3fb848cb35d8f3aacdf9ab62398d" + }, + { + "path": "Sources/Textual/TextProperty/Font/SmallCapsProperty.swift", + "upstream_sha256": "1414d54f272ab3b5cab9797997ffa637f6b2ea93ff47a5a25d9ab8cfc6eedbb4", + "local_sha256": "1414d54f272ab3b5cab9797997ffa637f6b2ea93ff47a5a25d9ab8cfc6eedbb4" + }, + { + "path": "Sources/Textual/TextProperty/Font/UppercaseSmallCapsProperty.swift", + "upstream_sha256": "f5b146f5beb717b1110b230c3685958882df0b6a66da628b7efbd1ed43726371", + "local_sha256": "f5b146f5beb717b1110b230c3685958882df0b6a66da628b7efbd1ed43726371" + }, + { + "path": "Sources/Textual/TextProperty/LineStyle/StrikethroughStyleProperty.swift", + "upstream_sha256": "5b3e4d668377d06ee7e1647d5bf42dfbf40e4fffdfafa18934803e67a61b10ed", + "local_sha256": "5b3e4d668377d06ee7e1647d5bf42dfbf40e4fffdfafa18934803e67a61b10ed" + }, + { + "path": "Sources/Textual/TextProperty/LineStyle/UnderlineStyleProperty.swift", + "upstream_sha256": "99373fda0293a988baa00eb0a755991382ae58fd1e25f1fe23f6e61afb71d341", + "local_sha256": "99373fda0293a988baa00eb0a755991382ae58fd1e25f1fe23f6e61afb71d341" + }, + { + "path": "Sources/Textual/TextProperty/Space/BaselineOffsetProperty.swift", + "upstream_sha256": "3b4e6e10acce959feb37074c9ea3a900df00828f2cd340122a7dc248bc31877d", + "local_sha256": "3b4e6e10acce959feb37074c9ea3a900df00828f2cd340122a7dc248bc31877d" + }, + { + "path": "Sources/Textual/TextProperty/Space/KerningProperty.swift", + "upstream_sha256": "18dada1a3271ae96300f4d86eef4d77f94ef00603bbb8f5f0241b1cd0ed17487", + "local_sha256": "18dada1a3271ae96300f4d86eef4d77f94ef00603bbb8f5f0241b1cd0ed17487" + }, + { + "path": "Sources/Textual/TextProperty/Space/TrackingProperty.swift", + "upstream_sha256": "5a04232cd618a2d2748df7035178c5d8631b531d96ec4a4f3f92d5c0271e7542", + "local_sha256": "5a04232cd618a2d2748df7035178c5d8631b531d96ec4a4f3f92d5c0271e7542" + }, + { + "path": "Sources/Textual/TextProperty/TextProperty.swift", + "upstream_sha256": "434d009fa2f451717587b57d2231dfef8a63945cf090ccec6c8bdf6d7e3182cf", + "local_sha256": "434d009fa2f451717587b57d2231dfef8a63945cf090ccec6c8bdf6d7e3182cf" + }, + { + "path": "Sources/Textual/TextProperty/TupleTextProperty.swift", + "upstream_sha256": "ed267c4e4b0a51d84e7c451b3b857376b83871d850cc4728836ca9e8a41ef6e9", + "local_sha256": "ed267c4e4b0a51d84e7c451b3b857376b83871d850cc4728836ca9e8a41ef6e9" + }, + { + "path": "Sources/Textual/TextualNamespace.swift", + "upstream_sha256": "c1da49bd9c2c076cec144210470f890a7d7f34ed6bd411b51fda83e924d547cf", + "local_sha256": "c1da49bd9c2c076cec144210470f890a7d7f34ed6bd411b51fda83e924d547cf" + }, + { + "path": "Sources/Textual/View+Textual.swift", + "upstream_sha256": "36fefb072da676deef914f086ecdecdc1960b2d9fc5c54dee87e5c29bc414709", + "local_sha256": "36fefb072da676deef914f086ecdecdc1960b2d9fc5c54dee87e5c29bc414709" + } + ] +} diff --git a/apps/maple-ios/Sources/MapleAppUI/Settings/PreferencePresentation.swift b/apps/maple-ios/Sources/MapleAppUI/Settings/PreferencePresentation.swift new file mode 100644 index 000000000..0708e73a4 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Settings/PreferencePresentation.swift @@ -0,0 +1,85 @@ +#if os(iOS) + import MapleAppCore + import SwiftUI + + extension MapleTheme { + public var preferredColorScheme: ColorScheme? { + switch self { + case .system: nil; + case .light: .light; + case .dark: .dark + } + } + public var messageRenderTheme: MessageRenderTheme { + switch self { + case .system: .automatic; + case .light: .light; + case .dark: .dark + } + } + var settingsLabel: String { + switch self { + case .system: "System"; + case .light: "Light"; + case .dark: "Dark" + } + } + } + + extension MapleChatFont { + public var messageRenderFont: MessageRenderFont { + switch self { + case .manrope: .manrope; + case .system: .system; + case .serif: .serif + } + } + var settingsLabel: String { + switch self { + case .manrope: "Manrope"; + case .system: "System"; + case .serif: "Serif" + } + } + var settingsDescription: String { + switch self { + case .manrope: "Maple's default, with a compact modern feel." + case .system: "The familiar interface font for your device." + case .serif: "A traditional reading style with distinct letterforms." + } + } + } + + extension MapleVoice { + var settingsGroup: String { + switch self { + case .neutralFemale, .neutralMale, .casualFemale, .casualMale, .cheerfulFemale: "Default voices" + default: "Reference accents" + } + } + var settingsLabel: String { + switch self { + case .neutralFemale: "Neutral — Female" + case .neutralMale: "Neutral — Male" + case .casualFemale: "Casual — Female" + case .casualMale: "Casual — Male" + case .cheerfulFemale: "Cheerful — Female" + case .arabicMale: "Arabic-accented — Male" + case .germanFemale: "German-accented — Female" + case .germanMale: "German-accented — Male" + case .spanishFemale: "Spanish-accented — Female" + case .spanishMale: "Spanish-accented — Male" + case .frenchFemale: "French-accented — Female" + case .frenchMale: "French-accented — Male" + case .hindiFemale: "Hindi-accented — Female" + case .hindiMale: "Hindi-accented — Male" + case .italianFemale: "Italian-accented — Female" + case .italianMale: "Italian-accented — Male" + case .dutchFemale: "Dutch-accented — Female" + case .dutchMale: "Dutch-accented — Male" + case .portugueseFemale: "Portuguese-accented — Female" + case .portugueseMale: "Portuguese-accented — Male" + } + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Settings/Settings.xcstrings b/apps/maple-ios/Sources/MapleAppUI/Settings/Settings.xcstrings new file mode 100644 index 000000000..9fab87081 --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Settings/Settings.xcstrings @@ -0,0 +1,1254 @@ +{ + "sourceLanguage": "en", + "strings": { + "A private AI workspace built for secure research and collaboration.": { + "comment": "About Maple tagline copied from Research.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A private AI workspace built for secure research and collaboration." + } + } + } + }, + "A traditional reading style with distinct letterforms.": { + "comment": "Description of selected Serif chat font.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A traditional reading style with distinct letterforms." + } + } + } + }, + "About Maple": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "About Maple" + } + } + } + }, + "Access to GLM 5.3, Kimi K2.6, and the full model lineup": { + "comment": "Model access notice benefit copied from Research. Model names are brand names.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Access to GLM 5.3, Kimi K2.6, and the full model lineup" + } + } + } + }, + "Account": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Account" + } + } + } + }, + "Account management": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Account management" + } + } + } + }, + "All models": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "All models" + } + } + } + }, + "All models run in secure, encrypted environments": { + "comment": "Model access notice benefit copied from Research.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "All models run in secure, encrypted environments" + } + } + } + }, + "Any voice can read any supported language; your text determines the spoken language. Matching the voice accent to the text generally sounds most natural.": { + "comment": "Read-aloud voice help copied from Research.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Any voice can read any supported language; your text determines the spoken language. Matching the voice accent to the text generally sounds most natural." + } + } + } + }, + "Appearance": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Appearance" + } + } + } + }, + "Arabic-accented — Male": { + "comment": "Read-aloud voice label. Describes the provider voice or reference accent; this does not select the interface language.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Arabic-accented — Male" + } + } + } + }, + "Casual — Female": { + "comment": "Read-aloud voice label. Describes the provider voice or reference accent; this does not select the interface language.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Casual — Female" + } + } + } + }, + "Casual — Male": { + "comment": "Read-aloud voice label. Describes the provider voice or reference accent; this does not select the interface language.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Casual — Male" + } + } + } + }, + "Change password": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Change password" + } + } + } + }, + "Chat font": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chat font" + } + } + } + }, + "Chat options": { + "comment": "Accessibility name for the static sliders button opening models, web search and project options.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chat options" + } + } + } + }, + "Checking plan…": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Checking plan…" + } + } + } + }, + "Cheerful — Female": { + "comment": "Read-aloud voice label. Describes the provider voice or reference accent; this does not select the interface language.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cheerful — Female" + } + } + } + }, + "Contact support": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Contact support" + } + } + } + }, + "Current plan": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Current plan" + } + } + } + }, + "Dark": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dark" + } + } + } + }, + "Default instructions": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Default instructions" + } + } + } + }, + "Default model": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Default model" + } + } + } + }, + "Default voices": { + "comment": "Group of the five default read-aloud voices.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Default voices" + } + } + } + }, + "Delete account": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete account" + } + } + } + }, + "Delete all chat history": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete all chat history" + } + } + } + }, + "Dutch-accented — Female": { + "comment": "Read-aloud voice label. Describes the provider voice or reference accent; this does not select the interface language.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dutch-accented — Female" + } + } + } + }, + "Dutch-accented — Male": { + "comment": "Read-aloud voice label. Describes the provider voice or reference accent; this does not select the interface language.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dutch-accented — Male" + } + } + } + }, + "Email not verified": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Email not verified" + } + } + } + }, + "Email verified": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Email verified" + } + } + } + }, + "Expires on": { + "comment": "Label for pass or Zaprite expiration date.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Expires on" + } + } + } + }, + "Free": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Free" + } + } + } + }, + "French-accented — Female": { + "comment": "Read-aloud voice label. Describes the provider voice or reference accent; this does not select the interface language.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "French-accented — Female" + } + } + } + }, + "French-accented — Male": { + "comment": "Read-aloud voice label. Describes the provider voice or reference accent; this does not select the interface language.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "French-accented — Male" + } + } + } + }, + "German-accented — Female": { + "comment": "Read-aloud voice label. Describes the provider voice or reference accent; this does not select the interface language.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "German-accented — Female" + } + } + } + }, + "German-accented — Male": { + "comment": "Read-aloud voice label. Describes the provider voice or reference accent; this does not select the interface language.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "German-accented — Male" + } + } + } + }, + "Get access to our most advanced AI models for superior performance": { + "comment": "Model access notice description copied from Research.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Get access to our most advanced AI models for superior performance" + } + } + } + }, + "Guest account": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Guest account" + } + } + } + }, + "Higher token limits for longer conversations": { + "comment": "Model access notice benefit copied from Research.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Higher token limits for longer conversations" + } + } + } + }, + "Hindi-accented — Female": { + "comment": "Read-aloud voice label. Describes the provider voice or reference accent; this does not select the interface language.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hindi-accented — Female" + } + } + } + }, + "Hindi-accented — Male": { + "comment": "Read-aloud voice label. Describes the provider voice or reference accent; this does not select the interface language.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hindi-accented — Male" + } + } + } + }, + "Italian-accented — Female": { + "comment": "Read-aloud voice label. Describes the provider voice or reference accent; this does not select the interface language.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Italian-accented — Female" + } + } + } + }, + "Italian-accented — Male": { + "comment": "Read-aloud voice label. Describes the provider voice or reference accent; this does not select the interface language.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Italian-accented — Male" + } + } + } + }, + "Light": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Light" + } + } + } + }, + "Loading preferences…": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading preferences…" + } + } + } + }, + "Manrope": { + "comment": "Font or product plan name. Preserve the proper name unless the product has an official localized name.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Manrope" + } + } + } + }, + "Maple's default, with a compact modern feel.": { + "comment": "Description of selected Manrope chat font.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Maple's default, with a compact modern feel." + } + } + } + }, + "Max": { + "comment": "Font or product plan name. Preserve the proper name unless the product has an official localized name.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Max" + } + } + } + }, + "Models could not be loaded.": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Models could not be loaded." + } + } + } + }, + "Neutral — Female": { + "comment": "Read-aloud voice label. Describes the provider voice or reference accent; this does not select the interface language.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Neutral — Female" + } + } + } + }, + "Neutral — Male": { + "comment": "Read-aloud voice label. Describes the provider voice or reference accent; this does not select the interface language.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Neutral — Male" + } + } + } + }, + "No compatible models are currently available.": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No compatible models are currently available." + } + } + } + }, + "OpenAI GPT-OSS, Gemma, and other advanced models": { + "comment": "Model access notice benefit copied from Research. Model names are brand names.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "OpenAI GPT-OSS, Gemma, and other advanced models" + } + } + } + }, + "Plan": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Plan" + } + } + } + }, + "Playback speed": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Playback speed" + } + } + } + }, + "Portuguese-accented — Female": { + "comment": "Read-aloud voice label. Describes the provider voice or reference accent; this does not select the interface language.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Portuguese-accented — Female" + } + } + } + }, + "Portuguese-accented — Male": { + "comment": "Read-aloud voice label. Describes the provider voice or reference accent; this does not select the interface language.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Portuguese-accented — Male" + } + } + } + }, + "Powerful AI Models": { + "comment": "Model availability notice title when no model name is supplied.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Powerful AI Models" + } + } + } + }, + "Preferences": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preferences" + } + } + } + }, + "Priority access to new models as they launch": { + "comment": "Model access notice benefit copied from Research.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Priority access to new models as they launch" + } + } + } + }, + "Privacy policy": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Privacy policy" + } + } + } + }, + "Pro": { + "comment": "Font or product plan name. Preserve the proper name unless the product has an official localized name.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pro" + } + } + } + }, + "Read aloud voice": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Read aloud voice" + } + } + } + }, + "Recommended": { + "comment": "Model picker section heading above the Quick and Powerful choices. Keep the same visual hierarchy as All models.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recommended" + } + } + } + }, + "Reference accents": { + "comment": "Group of read-aloud voices based on language accents.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reference accents" + } + } + } + }, + "Refresh account status": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh account status" + } + } + } + }, + "Renews on": { + "comment": "Label for subscription renewal date.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Renews on" + } + } + } + }, + "Reset password": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset password" + } + } + } + }, + "Retry saving preferences": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Retry saving preferences" + } + } + } + }, + "Saved preferences could not be loaded.": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Saved preferences could not be loaded." + } + } + } + }, + "Serif": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Serif" + } + } + } + }, + "Settings": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Settings" + } + } + } + }, + "Sign in again to change preferences.": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in again to change preferences." + } + } + } + }, + "Sign out": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign out" + } + } + } + }, + "Signed in": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Signed in" + } + } + } + }, + "Spanish-accented — Female": { + "comment": "Read-aloud voice label. Describes the provider voice or reference accent; this does not select the interface language.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Spanish-accented — Female" + } + } + } + }, + "Spanish-accented — Male": { + "comment": "Read-aloud voice label. Describes the provider voice or reference accent; this does not select the interface language.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Spanish-accented — Male" + } + } + } + }, + "System": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "System" + } + } + } + }, + "Team": { + "comment": "Font or product plan name. Preserve the proper name unless the product has an official localized name.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Team" + } + } + } + }, + "Terms of service": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terms of service" + } + } + } + }, + "Text size follows your system settings. Autoplay is off each time Maple launches.": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Text size follows your system settings. Autoplay is off each time Maple launches." + } + } + } + }, + "The familiar interface font for your device.": { + "comment": "Description of selected System chat font.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The familiar interface font for your device." + } + } + } + }, + "This change could not be saved on this device.": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This change could not be saved on this device." + } + } + } + }, + "Try again": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Try again" + } + } + } + }, + "Unavailable for your current account": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unavailable for your current account" + } + } + } + }, + "Unknown": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown" + } + } + } + }, + "Usage": { + "comment": "Settings section heading for the read-only plan, usage meter, reset and credit balance.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Usage" + } + } + } + }, + "Verify email": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Verify email" + } + } + } + }, + "Version": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Version" + } + } + } + }, + "Web search": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Web search" + } + } + } + }, + "Your account has reached its current chat allowance.": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your account has reached its current chat allowance." + } + } + } + }, + "Your plan could not be loaded. Try refreshing account status.": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your plan could not be loaded. Try refreshing account status." + } + } + } + }, + "Your team has more members than paid seats. Contact your team administrator.": { + "comment": "Native Settings screen. Keep complete messages and allow wrapping at large text sizes.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your team has more members than paid seats. Contact your team administrator." + } + } + } + }, + "settings.model-access-title": { + "comment": "Model availability notice title. Placeholder is the verbatim model name.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Access %@" + } + } + } + }, + "settings.plan-name": { + "comment": "Raw server product name followed by Plan. Placeholder is a product name; preserve it verbatim.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ Plan" + } + } + } + }, + "Default": { + "comment": "Research thinking-level selector label.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Default" + } + } + } + }, + "Extra high": { + "comment": "Research thinking-level selector label.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Extra high" + } + } + } + }, + "High": { + "comment": "Research thinking-level selector label.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "High" + } + } + } + }, + "Low": { + "comment": "Research thinking-level selector label.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Low" + } + } + } + }, + "Medium": { + "comment": "Research thinking-level selector label.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Medium" + } + } + } + }, + "Minimal": { + "comment": "Research thinking-level selector label.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Minimal" + } + } + } + }, + "Model default (%@)": { + "comment": "Model default thinking effort; placeholder is the localized lowercase effort label.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Model default (%@)" + } + } + } + }, + "Off": { + "comment": "Research thinking-level selector label.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Off" + } + } + } + }, + "Thinking": { + "comment": "Research thinking-level selector label.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Thinking" + } + } + } + } + }, + "version": "1.0" +} diff --git a/apps/maple-ios/Sources/MapleAppUI/Settings/SettingsScreen.swift b/apps/maple-ios/Sources/MapleAppUI/Settings/SettingsScreen.swift new file mode 100644 index 000000000..5d47d8e2c --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Settings/SettingsScreen.swift @@ -0,0 +1,99 @@ +#if os(iOS) + import MapleAppCore + import SwiftUI + + /// Settings is pushed by the compact menu and covered in its own native stack + /// on a wide window. Each account operation owns a new revocable ceremony. + struct SettingsScreen: View { + @Bindable var environment: AppEnvironment + let context: LibraryContext + @State private var destination: Destination? + @State private var confirmDeleteHistory = false + @State private var externalLink: URL? + + private enum Destination: Identifiable { + case instructions(InstructionEditor), account(AccountFlow) + var id: String { + switch self { + case .instructions: "instructions" + case .account(let flow): flow.id.uuidString + } + } + } + + var body: some View { + Group { + if let preferences = environment.preferences, let entitlements = environment.entitlements, + let user = environment.session.account?.user + { + SettingsView( + preferences: preferences, entitlements: entitlements, + account: SettingsAccount( + email: user.email, isVerified: user.emailVerified, + isGuest: user.loginMethod == "guest", + canChangePassword: ["email", "guest"].contains(user.loginMethod.lowercased())), + version: version, onAction: handle) + } else { + MapleSurface.canvas + } + } + .navigationDestination( + isPresented: Binding( + get: { destination != nil }, set: { if !$0 { destination = nil } } + ) + ) { + switch destination { + case .instructions(let editor): InstructionEditorView(editor: editor) + case .account(let flow): AccountFlowContent(flow: flow, session: environment.session) + case nil: EmptyView() + } + } + .confirmationDialog( + MapleStrings.text("Delete all chat history?"), isPresented: $confirmDeleteHistory, + titleVisibility: .visible + ) { + Button(MapleStrings.text("Delete all chats"), role: .destructive) { + Task { await context.root.deleteAllConversations() } + } + } message: { + Text( + MapleStrings.text( + "This permanently deletes your chats and their saved drafts and queued messages. Active requests must stop before deletion can finish." + ) + ) + } + .overlay(alignment: .bottom) { + if let issue = context.root.issue { LibraryIssueView(issue: issue).padding().background(.bar) } + } + .externalLinks($externalLink) + .safeAreaInset(edge: .bottom) { + if environment.settingsPresented, let listening = environment.listening { + CompactReplyPlayer(session: listening).padding(.horizontal, 8) + } + } + } + + private func handle(_ action: SettingsAction) { + switch action { + case .accountInstructions: destination = .instructions(context.makeInstructionEditor(.account)) + case .verifyEmail: open(.verification) + case .changePassword: open(.changePassword) + case .resetPassword: open(.reset) + case .deleteAccount: open(.deletion) + case .deleteAllHistory: confirmDeleteHistory = true + case .signOut: Task { await environment.session.signOut() } + case .externalURL(let url): externalLink = url + } + } + + private func open(_ screen: AccountScreen) { + destination = .account(AccountFlow(screen: screen, ceremonies: environment.makeCeremony(), code: nil)) + } + + private var version: String { + let bundle = Bundle.main + return + "\(bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "") (\(bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? ""))" + } + } +#endif diff --git a/apps/maple-ios/Sources/MapleAppUI/Settings/SettingsView.swift b/apps/maple-ios/Sources/MapleAppUI/Settings/SettingsView.swift new file mode 100644 index 000000000..6feed7a9b --- /dev/null +++ b/apps/maple-ios/Sources/MapleAppUI/Settings/SettingsView.swift @@ -0,0 +1,589 @@ +#if os(iOS) + import Foundation + import MapleAppCore + import SwiftUI + + public struct SettingsAccount: Equatable { + public let email: String? + public let isVerified: Bool + public let isGuest: Bool + public let canChangePassword: Bool + + public init(email: String?, isVerified: Bool, isGuest: Bool, canChangePassword: Bool) { + self.email = email; self.isVerified = isVerified + self.isGuest = isGuest; self.canChangePassword = canChangePassword + } + } + + /// Navigation and destructive workflows stay in the account-session owner. + /// External URLs must pass its confirmation/browser policy before opening. + public enum SettingsAction { + case accountInstructions, verifyEmail, changePassword, resetPassword + case deleteAllHistory, deleteAccount, signOut, externalURL(URL) + } + + public struct SettingsView: View { + @Bindable private var preferences: PreferencesController + @Bindable private var entitlements: AccountEntitlements + private let account: SettingsAccount + private let version: String + private let onAction: (SettingsAction) -> Void + + public init( + preferences: PreferencesController, entitlements: AccountEntitlements, + account: SettingsAccount, version: String, onAction: @escaping (SettingsAction) -> Void + ) { + self.preferences = preferences; self.entitlements = entitlements + self.account = account; self.version = version; self.onAction = onAction + } + + public var body: some View { + List { + accountSection + Section { + planRow + Button { + Task { + await entitlements.refreshBilling(); await entitlements.refreshTeam() + } + } label: { + settingsText("Refresh account status") + } + } header: { + settingsText("Plan") + } + Section { + LibraryUsageTile(account: entitlements) + } header: { + settingsText("Usage") + } + preferencesSection + Section { + Button { + onAction(.accountInstructions) + } label: { + settingsText("Default instructions") + } + if account.canChangePassword { + Button { + onAction(.changePassword) + } label: { + settingsText("Change password") + } + } + if account.email != nil { + Button { + onAction(.resetPassword) + } label: { + settingsText("Reset password") + } + } + Button(role: .destructive) { + onAction(.deleteAllHistory) + } label: { + settingsText("Delete all chat history") + .foregroundStyle(MapleRedText()) + }.accessibilityIdentifier("settings.delete-history") + Button(role: .destructive) { + onAction(.deleteAccount) + } label: { + settingsText("Delete account") + .foregroundStyle(MapleRedText()) + }.accessibilityIdentifier("settings.delete-account") + Button { + onAction(.signOut) + } label: { + settingsText("Sign out") + } + .accessibilityIdentifier("settings.sign-out") + } header: { + settingsText("Account management") + } + Section { + settingsText("A private AI workspace built for secure research and collaboration.") + .font(.subheadline).foregroundStyle(.secondary) + LabeledContent { + Text(verbatim: version) + } label: { + settingsText("Version") + } + externalRow("Privacy policy", "https://trymaple.ai/privacy") + externalRow("Terms of service", "https://trymaple.ai/terms") + externalRow("Contact support", "mailto:support@trymaple.ai") + } header: { + settingsText("About Maple") + } + } + .listStyle(.plain) + .listRowBackground(Color.clear) + .scrollContentBackground(.hidden) + .background(MapleSurface.canvas) + .navigationTitle(settingsText("Settings")) + .task { await preferences.load() } + .accessibilityIdentifier("settings.screen") + } + + private var accountSection: some View { + Section { + if let email = account.email { + Text(verbatim: email).textSelection(.enabled) + Label { + settingsText(account.isVerified ? "Email verified" : "Email not verified") + } icon: { + Image(systemName: account.isVerified ? "checkmark.seal" : "envelope.badge") + } + if !account.isVerified { + Button { + onAction(.verifyEmail) + } label: { + settingsText("Verify email") + } + .accessibilityIdentifier("settings.verify-email") + } + } else { + settingsText(account.isGuest ? "Guest account" : "Signed in") + } + + } header: { + settingsText("Account") + } + } + + @ViewBuilder private var planRow: some View { + switch entitlements.billing { + case .unknown, .loading: + LabeledContent { + Text(verbatim: "—").foregroundStyle(.secondary) + } label: { + settingsText("Current plan") + } + case .failed: + settingsText("Your plan could not be loaded. Try refreshing account status.") + case let .loaded(status): + LabeledContent { + if let name = status.productName { + Text( + String( + localized: "settings.plan-name", defaultValue: "\(name) Plan", + table: "Settings", bundle: .module) + ) + .accessibilityIdentifier("settings.plan-name") + } else { + Text(verbatim: "—").foregroundStyle(.secondary) + } + } label: { + settingsText("Current plan") + } + if let date = status.currentPeriodEnd?.date { + LabeledContent { + Text(date, format: .dateTime.year().month().day()) + } label: { + settingsText( + status.paymentProvider == "subscription_pass" || status.paymentProvider == "zaprite" + ? "Expires on" : "Renews on") + } + } + if !status.canChat || status.chatsRemaining == 0 { + settingsText("Your account has reached its current chat allowance.") + } + if entitlements.team.value?.seatMismatch != nil { + settingsText("Your team has more members than paid seats. Contact your team administrator.") + } + } + } + + private var preferencesSection: some View { + Section { + switch preferences.state { + case .idle, .loading: + preferenceControls.disabled(true) + case .failed: + settingsText("Saved preferences could not be loaded.") + Button { + Task { await preferences.load() } + } label: { + settingsText("Try again") + } + case .retired: settingsText("Sign in again to change preferences.") + case .ready: preferenceControls + } + } header: { + settingsText("Preferences") + } + } + + @ViewBuilder private var preferenceControls: some View { + Group { + Picker(selection: Binding(get: { preferences.value.theme }, set: { preferences.setTheme($0) })) { + ForEach(MapleTheme.allCases, id: \.self) { settingsText($0.settingsLabel).tag($0) } + } label: { + settingsText("Appearance") + } + .accessibilityIdentifier("settings.theme") + Picker( + selection: Binding(get: { preferences.value.chatFont }, set: { preferences.setChatFont($0) }) + ) { + ForEach(MapleChatFont.allCases, id: \.self) { settingsText($0.settingsLabel).tag($0) } + } label: { + settingsText("Chat font") + } + .accessibilityIdentifier("settings.font") + settingsText(preferences.value.chatFont.settingsDescription) + .font(.footnote).foregroundStyle(.secondary) + .accessibilityIdentifier("settings.font-description") + NavigationLink { + SettingsModelPicker(entitlements: entitlements) { id in preferences.setSelectedModelID(id) } + } label: { + settingsText("Default model") + } + Toggle( + isOn: Binding( + get: { preferences.value.webSearchPreferred }, + set: { + preferences.setWebSearchPreferred($0); entitlements.webSearchPreferred = $0 + } + ) + ) { settingsText("Web search") } + .disabled(entitlements.webSearchAccess != .allowed) + .accessibilityIdentifier("settings.web-search") + Picker(selection: Binding(get: { preferences.value.voice }, set: { preferences.setVoice($0) })) { + ForEach(["Default voices", "Reference accents"], id: \.self) { group in + Section { + ForEach(MapleVoice.allCases.filter { $0.settingsGroup == group }, id: \.self) { + settingsText($0.settingsLabel).tag($0) + } + } header: { + settingsText(group) + } + } + } label: { + settingsText("Read aloud voice") + } + .accessibilityIdentifier("settings.voice") + settingsText( + "Any voice can read any supported language; your text determines the spoken language. Matching the voice accent to the text generally sounds most natural." + ) + .font(.footnote).foregroundStyle(.secondary) + .accessibilityIdentifier("settings.voice-description") + Picker( + selection: Binding( + get: { preferences.value.playbackSpeed }, set: { preferences.setPlaybackSpeed($0) } + ) + ) { + ForEach(MaplePlaybackSpeed.allCases, id: \.self) { speed in + Text( + verbatim: "\(speed.rawValue.formatted(.number.precision(.fractionLength(0...2))))×" + ).tag(speed) + } + } label: { + settingsText("Playback speed") + } + .accessibilityIdentifier("settings.speed") + settingsText("Text size follows your system settings. Autoplay is off each time Maple launches.") + .font(.footnote).foregroundStyle(.secondary) + if preferences.saveIssue != nil { + settingsText("This change could not be saved on this device.") + Button { + preferences.retrySave() + } label: { + settingsText("Retry saving preferences") + } + } + } + } + + private func externalRow(_ title: String, _ address: String) -> some View { + Button { + if let url = URL(string: address) { onAction(.externalURL(url)) } + } label: { + Label { + settingsText(title) + } icon: { + Image(systemName: "arrow.up.right.square") + } + } + } + + } + + /// Fixed app-owned keys are maintained in Settings.xcstrings. Constructing + /// the key here keeps extraction from assigning helper literals to Localizable. + private func settingsText(_ key: String) -> Text { + Text(LocalizedStringKey(key), tableName: "Settings", bundle: .module) + } + + public struct SettingsModelPicker: View { + @Bindable private var entitlements: AccountEntitlements + private let webSearchPreference: Binding? + private let webSearchPreferences: PreferencesController? + private let projectRow: AnyView? + private let topContentMargin: CGFloat + private let canInteract: @MainActor () -> Bool + private let onSelection: (String) -> Void + @State private var modelNotice: ModelNotice? + + private struct ModelNotice: Identifiable { + let id = UUID() + let name: String + } + + public init( + entitlements: AccountEntitlements, webSearchPreference: Binding? = nil, + webSearchPreferences: PreferencesController? = nil, projectRow: AnyView? = nil, + topContentMargin: CGFloat = 24, + canInteract: @escaping @MainActor () -> Bool = { true }, onSelection: @escaping (String) -> Void + ) { + self.entitlements = entitlements; self.webSearchPreference = webSearchPreference + self.webSearchPreferences = webSearchPreferences; self.projectRow = projectRow + self.topContentMargin = topContentMargin + self.canInteract = canInteract; self.onSelection = onSelection + } + + public var body: some View { + List { + if hasOptionsGroup { + Section { + if let projectRow { + projectRow.padding(.top, 8) + .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)) + .listRowSeparator(.hidden) + } + if let webSearchPreference { + Toggle(isOn: webSearchPreference) { + HStack(spacing: 12) { + Image(systemName: "globe") + .foregroundStyle(MapleAccent.foreground) + .accessibilityHidden(true) + settingsText("Web search") + } + .font(MapleTypography.button) + } + .modifier(OptionRowSpacing(pairedWithProject: projectRow != nil)) + .listRowSeparator(.hidden) + .disabled( + (webSearchPreferences?.state ?? .ready) != .ready + || entitlements.webSearchAccess != .allowed + ) + .accessibilityIdentifier("model.web-search") + if let preferences = webSearchPreferences { + switch preferences.state { + case .idle, .loading: EmptyView() + case .failed: + settingsText("Saved preferences could not be loaded.") + Button { + Task { [weak preferences] in await preferences?.load() } + } label: { + settingsText("Try again") + } + .accessibilityIdentifier("model.preferences-retry") + case .retired: settingsText("Sign in again to change preferences.") + case .ready: EmptyView() + } + } + } + if let preferences = webSearchPreferences, !entitlements.resolvedThinkingLevel.options.isEmpty { + thinkingLevelRow(preferences) + } + } + .listRowSeparator(.hidden) + .listSectionSeparator(.hidden) + } + switch entitlements.catalog { + case .unknown, .loading: EmptyView() + case .failed: + settingsText("Models could not be loaded.") + Button { + Task { await entitlements.refreshCatalog() } + } label: { + settingsText("Try again") + } + case .loaded: + if !entitlements.primaryModels.isEmpty { + Section { + ForEach(entitlements.primaryModels) { modelRow($0) } + } header: { + modelSectionHeader("Recommended", separated: hasOptionsGroup) + } + .listSectionSeparator(.hidden) + } + if !entitlements.advancedModels.isEmpty { + Section { + ForEach(entitlements.advancedModels) { modelRow($0) } + } header: { + modelSectionHeader( + "All models", separated: hasOptionsGroup || !entitlements.primaryModels.isEmpty) + } + .listSectionSeparator(.hidden) + } + if entitlements.primaryModels.isEmpty && entitlements.advancedModels.isEmpty { + settingsText("No compatible models are currently available.") + } + } + } + .listStyle(.insetGrouped) + .listRowSeparator(.hidden) + .listSectionSpacing(16) + .contentMargins(.top, topContentMargin, for: .scrollContent) + .contentMargins(.bottom, 12, for: .scrollContent) + .listRowBackground(MapleSurface.card) + .scrollContentBackground(.hidden) + .background(MapleSurface.canvas) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .disabled(!canInteract()) + .accessibilityIdentifier("settings.models") + .sheet(item: $modelNotice) { notice in + MediaFeatureNotice(feature: .model, account: entitlements, modelName: notice.name) + } + } + + private var hasOptionsGroup: Bool { projectRow != nil || webSearchPreference != nil } + + private func thinkingLevelRow(_ preferences: PreferencesController) -> some View { + let resolved = entitlements.resolvedThinkingLevel + return Menu { + ForEach(resolved.options) { option in + Button { + guard canInteract() else { return } + preferences.setThinkingLevel(option.value) + } label: { + // Native menu actions extract the first sibling Text as + // their title and the second as their subtitle. + settingsText(option.label) + if let effort = option.defaultEffort { + let label = thinkingLevelLabel(effort) + Text( + String( + localized: "Model default (%@)", + defaultValue: "Model default (\(label.lowercased()))", table: "Settings", + bundle: .module) + ) + } + if option.value == resolved.effective { Image(systemName: "checkmark") } + } + .accessibilityIdentifier("model.thinking-level.\(option.value.rawValue)") + } + } label: { + HStack(spacing: 12) { + Image(systemName: "brain") + .foregroundStyle(MapleAccent.foreground) + .accessibilityHidden(true) + settingsText("Thinking") + Spacer() + settingsText(resolved.effective.label) + .foregroundStyle(.secondary) + Image(systemName: "chevron.up.chevron.down") + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + } + .font(MapleTypography.button) + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + .modifier(OptionRowSpacing(pairedWithProject: projectRow != nil)) + .listRowSeparator(.hidden) + .disabled(preferences.state != .ready) + .accessibilityIdentifier("model.thinking-level") + .accessibilityValue(settingsText(resolved.effective.label)) + } + + private func thinkingLevelLabel(_ level: MapleThinkingLevel) -> String { + switch level { + case .default: String(localized: "Default", table: "Settings", bundle: .module) + case .none: String(localized: "Off", table: "Settings", bundle: .module) + case .minimal: String(localized: "Minimal", table: "Settings", bundle: .module) + case .low: String(localized: "Low", table: "Settings", bundle: .module) + case .medium: String(localized: "Medium", table: "Settings", bundle: .module) + case .high: String(localized: "High", table: "Settings", bundle: .module) + case .xhigh: String(localized: "Extra high", table: "Settings", bundle: .module) + case .max: String(localized: "Max", table: "Settings", bundle: .module) + } + } + + private struct OptionRowSpacing: ViewModifier { + let pairedWithProject: Bool + func body(content: Content) -> some View { + if pairedWithProject { + content.frame(minHeight: 44).padding(.bottom, 8) + .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)) + } else { + content.padding(.vertical, 8) + } + } + } + + private func modelSectionHeader(_ title: String, separated: Bool) -> some View { + VStack(alignment: .leading, spacing: 12) { + if separated { + Divider().overlay(MapleSurface.border).accessibilityHidden(true) + } + settingsText(title) + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + .textCase(nil) + } + .padding(.top, 12) + .padding(.bottom, 8) + } + + private func modelRow(_ model: AccountModelChoice) -> some View { + Button { + guard canInteract() else { return } + if entitlements.selectModel(model.id) == .allowed { + onSelection(model.id) + } else { + let name = entitlements.advancedModels.first { $0.id == model.targetModel }?.label ?? model.label + modelNotice = ModelNotice(name: name) + } + } label: { + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text(verbatim: model.label) + .font(model.isAlias ? .title3.weight(.semibold) : MapleTypography.button) + .foregroundStyle(.primary) + let badges = model.badges.filter { + $0 != "Pro" && $0.lowercased() != model.accessTier.lowercased() + } + if !badges.isEmpty { + HStack(spacing: 6) { + ForEach(Array(badges.enumerated()), id: \.offset) { _, badge in + Text(verbatim: badge) + .font(.caption2.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 8).padding(.vertical, 4) + .background(MapleSurface.canvas, in: Capsule()) + } + } + } + if let description = model.description, !description.isEmpty { + Text(verbatim: description) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + Spacer(minLength: 8) + if entitlements.modelAccess(model) != .allowed { + Image(systemName: "lock").foregroundStyle(.secondary) + } + if entitlements.selectedModelID == model.id { + Image(systemName: "checkmark.circle.fill") + .font(.title3) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(MapleAccent.foreground) + } + } + .frame(minHeight: 48).contentShape(Rectangle()) + } + .buttonStyle(.plain) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 12, leading: 24, bottom: 12, trailing: 24)) + .accessibilityAddTraits(entitlements.selectedModelID == model.id ? .isSelected : []) + .accessibilityValue( + entitlements.modelAccess(model) == .allowed + ? Text(verbatim: "") : settingsText("Unavailable for your current account") + ) + .accessibilityIdentifier("settings.model.\(model.id)") + } + } +#endif diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Account/AccountEntitlementsTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Account/AccountEntitlementsTests.swift new file mode 100644 index 000000000..3f59e1550 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Account/AccountEntitlementsTests.swift @@ -0,0 +1,400 @@ +import Foundation +import MapleSDK +import Testing +@testable import MapleAppCore + +@MainActor struct AccountEntitlementsTests { + private func fixture( + plan: String? = "Pro", guest: Bool = false, canChat: Bool = true, remaining: Int? = nil, + catalog: ModelCatalog = accountCatalog(), selected: String = "auto:quick" + ) throws -> AccountEntitlements { + let service = AccountServingStub( + statuses: [.success(try billingStatus(plan: plan, canChat: canChat, remaining: remaining))], + teams: [.success(try basicTeam())], catalogs: [.success(catalog)]) + return AccountEntitlements( + scope: accountScope(), isGuest: guest, billing: service, catalog: service, selectedModelID: selected) + } + + @Test func unknownLoadingAndFailureNeverBecomeFree() async throws { + let gate = AccountGate() + let service = AccountServingStub( + statuses: [.failure(.unavailable)], teams: [.success(try basicTeam())], billingGates: [1: gate]) + let account = AccountEntitlements( + scope: accountScope(), isGuest: true, billing: service, catalog: service, + selectedModelID: "auto:powerful") + #expect(account.billing == .unknown) + #expect(account.plan == .unknown) + #expect(account.chatAccess == .allowed) + await account.refreshCatalog() + #expect(account.chatAccess == .allowed) + let load = Task { await account.refresh() } + await gate.waitUntilEntered() + #expect(account.billing == .loading) + #expect(account.plan == .unknown) + #expect(account.webSearchPreferred) + #expect(account.effectiveWebSearch && account.speechAvailable) + #expect(account.chatAccess == .allowed) + await gate.release(); await load.value + #expect(account.billing == .failed(.unavailable)) + #expect(account.plan == .unknown) + #expect(account.chatAccess == .allowed) + #expect(account.effectiveWebSearch && account.speechAvailable) + } + + @Test func explicitFreeEmailAndGuestDifferWithoutPurchaseActions() async throws { + let email = try fixture(plan: "Free") + let guest = try fixture(plan: "Free", guest: true) + await email.refresh(); await guest.refresh() + #expect(email.chatAccess == .allowed) + #expect(email.webSearchAccess == .allowed) + #expect(email.webSearchPreferred && email.effectiveWebSearch) + #expect(!email.transcriptionAvailable && !email.speechAvailable) + #expect(email.selectModel("auto:powerful") == .paidPlanRequired) + #expect(email.selectedModelID == "auto:quick") + #expect(guest.chatAccess == .freeGuest) + #expect(guest.webSearchAccess == .allowed && guest.effectiveWebSearch) + } + + @Test(arguments: ["Pro", "Max monthly", "Team Annual"]) + func paidPlansAndPaidGuestUseKnownCatalogCapabilities(_ plan: String) async throws { + let account = try fixture(plan: plan, guest: true) + await account.refresh() + #expect(account.plan.isPaid) + #expect(account.chatAccess == .allowed) + #expect(account.effectiveWebSearch) + #expect(account.transcriptionAvailable && account.speechAvailable) + #expect(account.selectModel("auto:powerful") == .allowed) + account.webSearchPreferred = false + #expect(!account.effectiveWebSearch) + } + + @Test func unknownPlanAndServerQuotaDoNotBlockFreeModelChat() async throws { + for account in [try fixture(plan: nil), try fixture(plan: "Future Premium", guest: true)] { + await account.refresh() + #expect(account.plan == .unknown) + #expect(account.chatAccess == .allowed) + #expect(account.effectiveWebSearch) + } + for account in [try fixture(canChat: false), try fixture(remaining: 0)] { + await account.refresh() + #expect(account.chatAccess == .allowed) + #expect(account.effectiveWebSearch) + } + } + + @Test func onlyLoadedKnownFreeGuestIsBlocked() async throws { + let service = AccountServingStub( + statuses: [.success(try billingStatus(plan: " FREE\n")), .failure(.unavailable)], + teams: [.success(try basicTeam())]) + let guest = AccountEntitlements(scope: accountScope(), isGuest: true, billing: service, catalog: service) + await guest.refreshCatalog() + #expect(guest.chatAccess == .allowed) + await guest.refreshBilling() + #expect(guest.chatAccess == .freeGuest) + await guest.refreshBilling() + #expect(guest.billing == .failed(.unavailable)) + #expect(guest.chatAccess == .allowed) + } + + @Test func webSearchKeepsPreferenceAcrossUnavailableAccountDetailsAndRetirement() async throws { + let service = AccountServingStub( + statuses: [.failure(.unavailable)], teams: [.failure(.unavailable)], + catalogs: [.failure(.unavailable)]) + let account = AccountEntitlements(scope: accountScope(), isGuest: true, billing: service, catalog: service) + #expect(account.webSearchAccess == .allowed && account.effectiveWebSearch) + await account.refresh() + #expect(account.billing == .failed(.unavailable) && account.catalog == .failed(.unavailable)) + #expect(account.webSearchAccess == .allowed && account.effectiveWebSearch) + #expect(account.speechAccess == .allowed) + account.webSearchPreferred = false + #expect(!account.effectiveWebSearch) + account.webSearchPreferred = true + #expect(account.effectiveWebSearch) + await account.retire() + #expect(account.webSearchPreferred && !account.effectiveWebSearch) + #expect(account.webSearchAccess == .retired && account.speechAccess == .retired) + } + + @Test func selectedPaidModelMayChatWithUnknownPlanWhilePickerRemainsPaidGated() async throws { + for plan: String? in [nil, "", "Future Premium"] { + let account = try fixture(plan: plan, selected: "auto:powerful") + await account.refresh() + #expect(account.plan == .unknown) + #expect(account.chatAccess == .allowed) + #expect(account.selectModel("auto:powerful") == .paidPlanRequired) + #expect(account.selectedModelID == "auto:powerful") + #expect(account.effectiveWebSearch) + } + } + + @Test func selectedPaidModelReachesServerAfterDowngradeAndBillingFailure() async throws { + for guest in [false, true] { + let service = AccountServingStub( + statuses: [ + .success(try billingStatus()), .success(try billingStatus(plan: "Free")), + .failure(.unavailable), + ], teams: [.success(try basicTeam())]) + let account = AccountEntitlements( + scope: accountScope(), isGuest: guest, billing: service, catalog: service, + selectedModelID: "auto:powerful") + await account.refresh() + #expect(account.chatAccess == .allowed) + await account.refreshBilling() + #expect(account.plan == .free) + #expect(account.chatAccess == (guest ? .freeGuest : .allowed)) + #expect(account.selectModel("auto:powerful") == .paidPlanRequired) + #expect(account.webSearchAccess == .allowed && account.effectiveWebSearch) + await account.refreshBilling() + #expect(account.billing == .failed(.unavailable)) + #expect(account.chatAccess == .allowed) + #expect(account.selectedModelID == "auto:powerful") + } + } + + @Test func teamCountsUseFallbacksAndMissingCountsStayUnknown() async throws { + let fallback: TeamStatus = try accountDecode([ + "has_team_subscription": true, "team_created": true, "members_count": 5, + "seats_purchased": 3, "seats_available": -2, + ]) + try fallback.validate() + #expect(fallback.memberCount == 5 && fallback.paidSeatCount == 3) + #expect(fallback.availableSeatCount == 0) + #expect(fallback.seatMismatch?.hasExactCounts == true) + let missing: TeamStatus = try accountDecode([ + "has_team_subscription": true, "team_created": true, "seat_limit_exceeded": true, + ]) + #expect(missing.seatMismatch?.hasExactCounts == false) + #expect(missing.seatMismatch?.memberCount == nil) + let precedence: TeamStatus = try accountDecode([ + "has_team_subscription": true, "team_created": true, "team_member_count": 2, + "members_count": 100, "billed_seat_count": 2, "seats_purchased": 0, + ]) + #expect(precedence.seatMismatch == nil) + let service = AccountServingStub( + statuses: [.success(try billingStatus(plan: "Team"))], teams: [.success(missing), .failure(.unavailable)]) + let account = AccountEntitlements(scope: accountScope(), isGuest: false, billing: service, catalog: service) + await account.refresh() + #expect(account.chatAccess == .allowed) + await account.refreshTeam() + #expect(account.team == .failed(.unavailable)) + #expect(account.team.value?.seatMismatch == nil) + #expect(account.billing.value?.plan == .paid(.team)) + } + + @Test func catalogFiltersDisabledDeprecatedNonChatAndPaidGatesEveryNonFreeTier() async throws { + let catalog = accountCatalog(items: [ + catalogItem(), catalogItem("power-target", access: "pro"), catalogItem("off", enabled: false), + catalogItem("old", deprecated: true), catalogItem("audio", chat: false), + catalogItem("future", access: "future"), + catalogItem(), // duplicate server entry cannot duplicate menus + ]) + let account = try fixture(catalog: catalog) + await account.refresh() + #expect(Set(account.advancedModels.map(\.id)) == ["quick-target", "power-target", "future"]) + #expect(account.advancedModels.count == 3) + #expect(account.primaryModels.map(\.id) == ["auto:quick", "auto:powerful"]) + #expect(account.selectModel("future") == .allowed) + #expect(account.selectedModelID == "future") + let free = try fixture(plan: "Free", catalog: catalog) + await free.refresh() + #expect(free.selectModel("future") == .paidPlanRequired) + #expect(free.selectedModelID == "auto:quick") + for id in ["off", "old", "audio"] { + #expect(account.selectModel(id) == .modelUnavailable) + } + } + + @Test func catalogFailurePreservesSelectionAndStillAdmitsSending() async throws { + let service = AccountServingStub( + statuses: [.success(try billingStatus())], teams: [.success(try basicTeam())], + catalogs: [.success(accountCatalog()), .failure(.unavailable)]) + let account = AccountEntitlements( + scope: accountScope(), isGuest: false, billing: service, catalog: service, + selectedModelID: "power-target") + await account.refresh() + #expect(account.selectedModelID == "power-target") + await account.refreshCatalog() + #expect(account.selectedModelID == "power-target") + #expect(account.catalog == .failed(.unavailable)) + #expect(account.chatAccess == .allowed) + #expect(account.plan == .paid(.pro)) + #expect(account.primaryModels.isEmpty) + } + + @Test(arguments: ["auto:quick", "auto:powerful", "saved-model"]) + func missingLoadingAndFailedCatalogAllowValidSavedSelection(_ selected: String) async throws { + let gate = AccountGate() + let service = AccountServingStub( + statuses: [.success(try billingStatus())], teams: [.success(try basicTeam())], + catalogs: [.failure(.unavailable)], catalogGates: [1: gate]) + let account = AccountEntitlements( + scope: accountScope(), isGuest: false, billing: service, catalog: service, + selectedModelID: selected) + #expect(account.catalog == .unknown && account.selectedModel == nil) + #expect(account.chatAccess == .allowed && account.selectedModelID == selected) + let refresh = Task { await account.refreshCatalog() } + await gate.waitUntilEntered() + #expect(account.catalog == .loading && account.chatAccess == .allowed) + #expect(account.selectedModelID == selected) + await gate.release(); await refresh.value + #expect(account.catalog == .failed(.unavailable) && account.selectedModel == nil) + #expect(account.chatAccess == .allowed && account.selectedModelID == selected) + await account.retire() + #expect(account.chatAccess == .retired) + } + + @Test func missingCatalogDoesNotBypassKnownFreeGuestOrInvalidSelection() async throws { + let guest = try fixture(plan: "Free", guest: true) + await guest.refreshBilling() + #expect(guest.catalog == .unknown && guest.chatAccess == .freeGuest) + for invalid in ["", "invalid model", "model\n", String(repeating: "a", count: 257)] { + let account = try fixture(selected: invalid) + #expect(account.catalog == .unknown && account.chatAccess == .modelUnavailable) + } + } + + @Test func successfulCatalogReconcilesRemovedConcreteModelAndKeepsAliases() async throws { + let first = accountCatalog() + let second = accountCatalog( + items: [catalogItem("new-target"), catalogItem("power-target", access: "pro")], quickTarget: "new-target") + let service = AccountServingStub( + statuses: [.success(try billingStatus())], teams: [.success(try basicTeam())], + catalogs: [.success(first), .success(second)]) + let account = AccountEntitlements( + scope: accountScope(), isGuest: false, billing: service, catalog: service, + selectedModelID: "quick-target") + await account.refresh(); await account.refreshCatalog() + #expect(account.selectedModelID == "auto:quick") + #expect(account.selectedModel?.targetModel == "new-target") + await account.refreshCatalog() + #expect(account.selectedModelID == "auto:quick") + } + + @Test func actualAliasTargetOverridesAccessWithoutGatingWebSearchOnTools() async throws { + let catalog = accountCatalog(items: [ + catalogItem(access: "pro", tools: false), catalogItem("power-target", access: "pro"), + ]) + let free = try fixture(plan: "Free", catalog: catalog) + let paid = try fixture(catalog: catalog) + await free.refresh(); await paid.refresh() + #expect(free.chatAccess == .allowed) + #expect(free.selectModel("auto:quick") == .paidPlanRequired) + #expect(paid.chatAccess == .allowed) + #expect(paid.webSearchAccess == .allowed) + #expect(paid.webSearchPreferred && paid.effectiveWebSearch) + } + + @Test func newerRefreshWinsOverLateBillingAndCatalogResults() async throws { + let billingGate = AccountGate(); let catalogGate = AccountGate() + let service = AccountServingStub( + statuses: [.success(try billingStatus(plan: "Free")), .success(try billingStatus())], + teams: [.success(try basicTeam())], + catalogs: [.success(accountCatalog()), .failure(.unavailable)], + billingGates: [1: billingGate], catalogGates: [1: catalogGate]) + let account = AccountEntitlements(scope: accountScope(), isGuest: false, billing: service, catalog: service) + let first = Task { await account.refresh() } + await billingGate.waitUntilEntered(); await catalogGate.waitUntilEntered() + await account.refresh() + #expect(account.plan == .paid(.pro)) + #expect(account.catalog == .failed(.unavailable)) + await billingGate.release(); await catalogGate.release(); await first.value + #expect(account.plan == .paid(.pro)) + #expect(account.catalog == .failed(.unavailable)) + } + + @Test func samePrincipalReloginCannotReceiveRetiredGenerationResults() async throws { + let gate = AccountGate() + let service = AccountServingStub( + statuses: [.success(try billingStatus())], teams: [.success(try basicTeam())], billingGates: [1: gate]) + let old = AccountEntitlements(scope: accountScope(), isGuest: false, billing: service, catalog: service) + let load = Task { await old.refresh() } + await gate.waitUntilEntered() + await old.retire() + let replacement = try fixture(plan: "Free") + #expect(old.scope.principalID == replacement.scope.principalID) + #expect(old.scope.generation != replacement.scope.generation) + await replacement.refresh(); await gate.release(); await load.value + #expect(old.billing == .unknown && old.catalog == .unknown) + #expect(old.chatAccess == .retired) + #expect(replacement.plan == .free) + #expect(await service.retired) + let calls = await service.statusCalls + await old.refresh() + #expect(await service.statusCalls == calls) + } + + @Test func billingRefreshRetainsPaidImageAccessUntilDenialAndNeverRevivesFailedValue() async throws { + let denied = AccountGate(), failed = AccountGate(), retried = AccountGate() + let service = AccountServingStub( + statuses: [ + .success(try billingStatus()), .success(try billingStatus(plan: "Free")), + .failure(.unavailable), .success(try billingStatus()), + ], teams: [.success(try basicTeam())], billingGates: [2: denied, 3: failed, 4: retried]) + let account = AccountEntitlements(scope: accountScope(), isGuest: false, billing: service, catalog: service) + await account.refresh() + let refresh = Task { await account.refreshBilling() } + await denied.waitUntilEntered() + #expect(account.imageAccess == .allowed && account.plan.isPaid) + await denied.release(); await refresh.value + #expect(account.imageAccess == .paidPlanRequired && account.plan == .free) + let failure = Task { await account.refreshBilling() } + await failed.waitUntilEntered() + #expect(account.imageAccess == .paidPlanRequired && account.speechAccess == .paidPlanRequired) + await failed.release(); await failure.value + #expect(account.imageAccess == .unavailable && account.billing == .failed(.unavailable)) + #expect(account.speechAccess == .allowed && account.effectiveWebSearch) + let retry = Task { await account.refreshBilling() } + await retried.waitUntilEntered() + #expect(account.billing == .loading && account.plan == .unknown && account.imageAccess == .unavailable) + #expect(account.speechAccess == .allowed && account.effectiveWebSearch) + await retried.release(); await retry.value + #expect(account.imageAccess == .allowed && account.speechAccess == .allowed) + await account.retire() + #expect(account.speechAccess == .retired && account.billing == .unknown && account.catalog == .unknown) + #expect(account.webSearchAccess == .retired && !account.effectiveWebSearch) + } + + @Test func catalogRefreshRetainsKnownCapabilityUntilFailureThenRetryStartsUnknown() async throws { + let failed = AccountGate(), retried = AccountGate() + var unavailable = accountCatalog() + unavailable.audio?.transcription = AudioModelAvailability(available: false, model: "whisper-large-v3") + let service = AccountServingStub( + statuses: [.success(try billingStatus())], teams: [.success(try basicTeam())], + catalogs: [.success(accountCatalog()), .failure(.unavailable), .success(unavailable)], + catalogGates: [2: failed, 3: retried]) + let account = AccountEntitlements(scope: accountScope(), isGuest: false, billing: service, catalog: service) + await account.refresh() + let failure = Task { await account.refreshCatalog() } + await failed.waitUntilEntered() + #expect(account.transcriptionAccess == .allowed && !account.primaryModels.isEmpty) + await failed.release(); await failure.value + #expect(account.transcriptionAccess == .unavailable && account.catalog == .failed(.unavailable)) + #expect(account.speechAccess == .allowed) + let retry = Task { await account.refreshCatalog() } + await retried.waitUntilEntered() + #expect(account.catalog == .loading && account.transcriptionAccess == .unavailable) + await retried.release(); await retry.value + #expect(account.transcriptionAccess == .modelIncompatible && !account.transcriptionAvailable) + #expect(account.speechAvailable) + } + + @Test func speechAdmissionBlocksOnlyKnownFreePlanAndRetiredSession() async throws { + let unknown = try fixture(plan: "Future Premium"), free = try fixture(plan: "Free", guest: true) + let exhausted = try fixture(remaining: 0) + var missing = accountCatalog(); missing.audio = nil + let absent = try fixture(catalog: missing) + #expect(unknown.speechAccess == .allowed) + await unknown.refresh(); await free.refresh(); await exhausted.refresh(); await absent.refresh() + #expect(unknown.speechAccess == .allowed) + #expect(free.speechAccess == .paidPlanRequired) + #expect(exhausted.speechAccess == .allowed) + #expect(absent.speechAccess == .allowed) + var disabled = accountCatalog() + disabled.audio?.speech = AudioModelAvailability(available: false, model: "voxtral-tts") + let disabledSpeech = try fixture(catalog: disabled) + await disabledSpeech.refresh() + #expect(disabledSpeech.speechAccess == .allowed) + await disabledSpeech.retire() + #expect(disabledSpeech.speechAccess == .retired && !disabledSpeech.speechAvailable) + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Account/AccountFixtures.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Account/AccountFixtures.swift new file mode 100644 index 000000000..ec6f185d0 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Account/AccountFixtures.swift @@ -0,0 +1,152 @@ +import Foundation +import MapleSDK +@testable import MapleAppCore + +func accountConfiguration() throws -> AppConfiguration { + try AppConfiguration( + profile: .local, apiURL: "http://127.0.0.1:11001", billingURL: "http://127.0.0.1:11002", + flagsURL: "http://127.0.0.1:11003", authURL: "http://127.0.0.1:11004", + clientID: "11111111-1111-4111-8111-111111111111", callbackScheme: "cloud.opensecret.maple.native.dev") +} + +func accountScope() -> AccountScope { AccountScope(apiOrigin: "http://127.0.0.1:11001", principalID: "fixture") } + +func accountDecode(_ value: [String: Any], as: Value.Type = Value.self) throws -> Value { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return try decoder.decode(Value.self, from: JSONSerialization.data(withJSONObject: value)) +} + +func billingBody(plan: String? = "Pro", canChat: Bool = true, remaining: Int? = nil) throws -> Data { + try JSONSerialization.data(withJSONObject: [ + "is_subscribed": plan != "Free", "product_id": "fixture-plan", "product_name": plan as Any? ?? NSNull(), + "subscription_status": "active", "current_period_end": NSNull(), "can_chat": canChat, + "chats_remaining": remaining as Any? ?? NSNull(), "payment_provider": NSNull(), + "total_tokens": NSNull(), "used_tokens": NSNull(), "usage_reset_date": NSNull(), + "ios_iap_enabled": true, "ios_us_external_link_enabled": true, + ]) +} + +func billingStatus(plan: String? = "Pro", canChat: Bool = true, remaining: Int? = nil) throws -> BillingStatus { + let decoder = JSONDecoder(); decoder.keyDecodingStrategy = .convertFromSnakeCase + return try decoder.decode(BillingStatus.self, from: billingBody(plan: plan, canChat: canChat, remaining: remaining)) +} + +func basicTeam() throws -> TeamStatus { + try accountDecode(["has_team_subscription": false, "team_created": false]) +} + +func catalogItem( + _ id: String = "quick-target", access: String = "free", tools: Bool = true, + enabled: Bool = true, deprecated: Bool = false, chat: Bool = true, order: Int = 0 +) -> ModelCatalogItem { + ModelCatalogItem( + id: id, object: "model", created: 0, ownedBy: "fixture", displayName: "Model \(id)", + shortName: id, contextWindow: 131_072, maxContextTokens: 131_072, access: access, + capabilities: ModelCapabilities(chat: chat, vision: false, reasoning: true, toolUse: tools), + enabled: enabled, deprecated: deprecated, sortOrder: order) +} + +func accountCatalog(items: [ModelCatalogItem]? = nil, quickTarget: String = "quick-target") -> ModelCatalog { + let capabilities = ModelCapabilities(chat: true, vision: false, reasoning: true, toolUse: true) + return ModelCatalog( + object: "list", data: items ?? [catalogItem(), catalogItem("power-target", access: "pro")], + aliases: [ + ModelAlias( + id: .quick, label: "Quick", shortName: "Quick", description: "Fast", + targetModel: quickTarget, access: "free", capabilities: capabilities), + ModelAlias( + id: .powerful, label: "Powerful", shortName: "Powerful", description: "Deep", + targetModel: "power-target", access: "pro", capabilities: capabilities), + ], + defaults: ModelDefaults(quick: .quick, powerful: .powerful), + audio: AudioModelCatalog( + transcription: AudioModelAvailability(available: true, model: "whisper-large-v3"), + speech: AudioModelAvailability(available: true, model: "voxtral-tts"))) +} + +actor AccountGate { + private var open = false + private var waiters: [CheckedContinuation] = [] + private var entryWaiters: [CheckedContinuation] = [] + private(set) var entries = 0 + func wait() async { + entries += 1 + let entered = entryWaiters; entryWaiters.removeAll() + entered.forEach { $0.resume() } + if !open { await withCheckedContinuation { waiters.append($0) } } + } + /// Synchronize with service entry rather than assuming a yield schedules it. + func waitUntilEntered() async { + if entries > 0 { return } + await withCheckedContinuation { entryWaiters.append($0) } + } + func release() { + open = true + let pending = waiters; waiters.removeAll() + pending.forEach { $0.resume() } + } +} + +actor AccountTokenStub: BillingTokenSource { + private(set) var audiences: [String] = [] + let gate: AccountGate? + let failure: AccountServiceError? + init(gate: AccountGate? = nil, failure: AccountServiceError? = nil) { self.gate = gate; self.failure = failure } + func mintToken(audience: String) async throws -> String { + audiences.append(audience) + let index = audiences.count + if let gate { await gate.wait() } + if let failure { throw failure } + return "fixture-token-\(index)" + } +} + +actor AccountHTTPStub: BillingHTTP { + struct Call: Sendable { let url: URL; let token: String } + private(set) var calls: [Call] = [] + let handler: @Sendable (URL, String, Int) async throws -> BillingHTTPResponse + init(handler: @escaping @Sendable (URL, String, Int) async throws -> BillingHTTPResponse) { self.handler = handler } + func get(url: URL, bearerToken: String) async throws -> BillingHTTPResponse { + calls.append(Call(url: url, token: bearerToken)) + return try await handler(url, bearerToken, calls.count) + } +} + +actor AccountServingStub: BillingServing, ModelCatalogServing { + var statuses: [Result] + var teams: [Result] + var catalogs: [Result] + let billingGates: [Int: AccountGate] + let catalogGates: [Int: AccountGate] + private(set) var statusCalls = 0 + private(set) var teamCalls = 0 + private(set) var catalogCalls = 0 + private(set) var retired = false + init( + statuses: [Result], + teams: [Result], + catalogs: [Result] = [.success(accountCatalog())], + billingGates: [Int: AccountGate] = [:], catalogGates: [Int: AccountGate] = [:] + ) { + self.statuses = statuses; self.teams = teams; self.catalogs = catalogs + self.billingGates = billingGates; self.catalogGates = catalogGates + } + func subscriptionStatus() async throws -> BillingStatus { + statusCalls += 1 + let value = statuses[min(statusCalls - 1, statuses.count - 1)] + if let gate = billingGates[statusCalls] { await gate.wait() } + return try value.get() + } + func teamStatus() throws -> TeamStatus { + teamCalls += 1 + return try teams[min(teamCalls - 1, teams.count - 1)].get() + } + func fetchCatalog() async throws -> ModelCatalog { + catalogCalls += 1 + let value = catalogs[min(catalogCalls - 1, catalogs.count - 1)] + if let gate = catalogGates[catalogCalls] { await gate.wait() } + return try value.get() + } + func retire() { retired = true } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Account/BillingServiceTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Account/BillingServiceTests.swift new file mode 100644 index 000000000..25bb5ae7c --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Account/BillingServiceTests.swift @@ -0,0 +1,247 @@ +import Foundation +import Testing +@testable import MapleAppCore + +struct BillingServiceTests { + @Test func fixedRoutesAndAudienceOnlyUseThirdPartyToken() async throws { + let source = AccountTokenStub() + let http = AccountHTTPStub { url, _, _ in + let body = + url.path.hasSuffix("team/status") + ? Data(#"{"has_team_subscription":false,"team_created":false}"#.utf8) : try billingBody() + return BillingHTTPResponse(status: 200, body: body) + } + let service = BillingService( + scope: accountScope(), configuration: try accountConfiguration(), tokenSource: source, http: http) + async let subscription = service.subscriptionStatus() + async let team = service.teamStatus() + _ = try await (subscription, team) + #expect(await source.audiences == ["http://127.0.0.1:11002"]) + let calls = await http.calls + #expect(Set(calls.map(\.url.path)) == ["/v1/maple/subscription/status", "/v1/maple/team/status"]) + #expect( + calls.allSatisfy { $0.url.host == "127.0.0.1" && $0.url.port == 11002 && $0.token == "fixture-token-1" }) + } + + @Test func concurrentMintIsDeduplicated() async throws { + let gate = AccountGate(); let source = AccountTokenStub(gate: gate) + let http = AccountHTTPStub { _, _, _ in BillingHTTPResponse(status: 200, body: try billingBody()) } + let service = BillingService( + scope: accountScope(), configuration: try accountConfiguration(), tokenSource: source, http: http) + let first = Task { try await service.subscriptionStatus() } + let second = Task { try await service.subscriptionStatus() } + await gate.waitUntilEntered() + #expect(await gate.entries == 1) + await gate.release() + _ = try await (first.value, second.value) + #expect(await source.audiences.count == 1) + } + + @Test func unauthorizedRemintsOnceAndNeverRetriesOtherStatus() async throws { + for status in [401, 403, 500] { + let source = AccountTokenStub() + let http = AccountHTTPStub { _, _, _ in BillingHTTPResponse(status: status, body: Data()) } + let service = BillingService( + scope: accountScope(), configuration: try accountConfiguration(), tokenSource: source, http: http) + await #expect(throws: status == 401 ? AccountServiceError.unauthorized : .unavailable) { + try await service.subscriptionStatus() + } + #expect(await source.audiences.count == (status == 401 ? 2 : 1)) + #expect(await http.calls.count == (status == 401 ? 2 : 1)) + } + } + + @Test func lateUnauthorizedCannotEvictReplacementToken() async throws { + let gate = AccountGate(); let source = AccountTokenStub() + let http = AccountHTTPStub { _, token, index in + if index == 1 { await gate.wait() } + return BillingHTTPResponse(status: token == "fixture-token-1" ? 401 : 200, body: try billingBody()) + } + let service = BillingService( + scope: accountScope(), configuration: try accountConfiguration(), tokenSource: source, http: http) + let first = Task { try await service.subscriptionStatus() } + await gate.waitUntilEntered() + _ = try await service.subscriptionStatus() // replaces token while the first 401 is held + await gate.release() + _ = try await first.value + #expect(await source.audiences.count == 2) + #expect( + await http.calls.map(\.token) == [ + "fixture-token-1", "fixture-token-1", "fixture-token-2", "fixture-token-2", + ]) + } + + @Test func retirementDuringMintCannotSendHTTP() async throws { + let gate = AccountGate(); let source = AccountTokenStub(gate: gate) + let http = AccountHTTPStub { _, _, _ in BillingHTTPResponse(status: 200, body: try billingBody()) } + let service = BillingService( + scope: accountScope(), configuration: try accountConfiguration(), tokenSource: source, http: http) + let work = Task { try await service.subscriptionStatus() } + await gate.waitUntilEntered() + await service.retire(); await gate.release() + await #expect(throws: AccountServiceError.sessionChanged) { try await work.value } + #expect(await http.calls.isEmpty) + } + + @Test func retirementDuringHTTPRejectsResultAndFurtherMint() async throws { + let gate = AccountGate(); let source = AccountTokenStub() + let http = AccountHTTPStub { _, _, _ in + await gate.wait() + return BillingHTTPResponse(status: 401, body: Data()) + } + let service = BillingService( + scope: accountScope(), configuration: try accountConfiguration(), tokenSource: source, http: http) + let work = Task { try await service.subscriptionStatus() } + await gate.waitUntilEntered() + await service.retire(); await gate.release() + await #expect(throws: AccountServiceError.sessionChanged) { try await work.value } + await #expect(throws: AccountServiceError.sessionChanged) { try await service.teamStatus() } + #expect(await source.audiences.count == 1) + } + + @Test func invalidAndOversizedBodiesRemainExplicitFailures() async throws { + for data in [ + Data(#"{"product_name":"Free"}"#.utf8), Data("not json".utf8), Data(repeating: 32, count: 1_048_577), + ] { + let source = AccountTokenStub() + let http = AccountHTTPStub { _, _, _ in BillingHTTPResponse(status: 200, body: data) } + let service = BillingService( + scope: accountScope(), configuration: try accountConfiguration(), tokenSource: source, http: http) + await #expect(throws: AccountServiceError.invalidResponse) { try await service.subscriptionStatus() } + #expect(await source.audiences.count == 1) + } + } + + @Test func malformedCountsAndUnknownPlanAreNotFree() throws { + #expect(try billingStatus(plan: nil).plan == .unknown) + #expect(try billingStatus(plan: "Future Premium").plan == .unknown) + #expect(try billingStatus(plan: "Free plan").plan == .unknown) + #expect(try billingStatus(plan: "").plan == .unknown) + #expect(try billingStatus(plan: " FREE\n").plan == .free) + #expect(try billingStatus(plan: " Pro annual\n").plan == .paid(.pro)) + #expect(try billingStatus(plan: "MAX monthly").plan == .paid(.max)) + #expect(try billingStatus(plan: "Team Annual").plan == .paid(.team)) + #expect(throws: AccountServiceError.invalidResponse) { try billingStatus(remaining: -1).validate() } + #expect(throws: (any Error).self) { + let _: TeamStatus = try accountDecode([ + "has_team_subscription": true, "team_created": true, "seats_used": 1.5, + ]) + } + } + + @Test func fullBillingStatusFixtureDecodesOptionalContractFields() throws { + let status: BillingStatus = try accountDecode([ + "is_subscribed": true, "stripe_customer_id": "fixture-customer", "product_id": "fixture-product", + "product_name": "Max", "subscription_status": "active", "current_period_end": 1_735_689_600, + "can_chat": true, "chats_remaining": 42, "payment_provider": "stripe", + "total_tokens": 1_000_000, "used_tokens": 123_456, "usage_reset_date": "2025-01-01", + "api_credit_balance": 12_345.5, "ios_iap_enabled": true, "ios_us_external_link_enabled": false, + "subscriptions": [ + [ + "provider": "stripe", "plan": "Max", "product_id": "fixture-product", "state": "active", + "renews_at": 1_735_689_600, "manage": "portal", "selected": true, + "environment": "fixture", "auto_renew_enabled": true, + ] + ], + "conflict": ["other_provider": "apple", "action": "future_action"], + "pending_plan_change": [ + "id": "fixture-change", "type": "downgrade", "target_plan_name": "Pro", "status": "pending", + "expires_at": "2025-01-01T00:00:00Z", + ], + ]) + try status.validate() + #expect(status.isSubscribed && status.stripeCustomerId == "fixture-customer") + #expect(status.productId == "fixture-product" && status.productName == "Max") + #expect(status.subscriptionStatus == "active" && status.currentPeriodEnd == .seconds(1_735_689_600)) + #expect(status.canChat && status.chatsRemaining == 42 && status.paymentProvider == "stripe") + #expect(status.totalTokens == 1_000_000 && status.usedTokens == 123_456) + #expect(status.usageResetDate == "2025-01-01") + #expect(status.apiCreditBalance == 12_345.5) + #expect(status.iosIapEnabled == true && status.iosUsExternalLinkEnabled == false) + let subscription = try #require(status.subscriptions?.first) + #expect(subscription.provider == "stripe" && subscription.plan == "Max") + #expect(subscription.productId == "fixture-product" && subscription.state == "active") + #expect(subscription.renewsAt == 1_735_689_600 && subscription.manage == "portal") + #expect(subscription.selected == true && subscription.environment == "fixture") + #expect(subscription.autoRenewEnabled == true) + let conflict = try #require(status.conflict) + #expect(conflict.otherProvider == "apple" && conflict.action == "future_action") + let pending = try #require(status.pendingPlanChange) + #expect(pending.id == "fixture-change" && pending.type == "downgrade") + #expect(pending.targetPlanName == "Pro" && pending.status == "pending") + #expect(pending.expiresAt == "2025-01-01T00:00:00Z") + } + + @Test func optionalBillingFieldsMayBeAbsentOrNull() throws { + var payload = try #require(JSONSerialization.jsonObject(with: billingBody()) as? [String: Any]) + let optional = [ + "stripe_customer_id", "api_credit_balance", "ios_iap_enabled", "ios_us_external_link_enabled", + "conflict", "pending_plan_change", + ] + for nulls in [false, true] { + for key in optional { + if nulls { payload[key] = NSNull() } else { payload.removeValue(forKey: key) } + } + let status: BillingStatus = try accountDecode(payload) + #expect(status.stripeCustomerId == nil && status.apiCreditBalance == nil) + #expect(status.iosIapEnabled == nil && status.iosUsExternalLinkEnabled == nil) + #expect(status.conflict == nil && status.pendingPlanChange == nil) + } + } + + @Test(arguments: ["other_provider", "action", "id", "type", "target_plan_name", "status", "expires_at"]) + func missingOrNullBillingHintSubfieldsDoNotInvalidateStatus(_ field: String) throws { + for nulls in [false, true] { + var payload = try #require(JSONSerialization.jsonObject(with: billingBody()) as? [String: Any]) + var conflict: [String: Any] = ["other_provider": "apple", "action": "future_action"] + var pending: [String: Any] = [ + "id": "fixture-change", "type": "downgrade", "target_plan_name": "Max", "status": "pending", + "expires_at": "2025-01-01T00:00:00Z", + ] + if conflict[field] != nil { + if nulls { conflict[field] = NSNull() } else { conflict.removeValue(forKey: field) } + } else { + if nulls { pending[field] = NSNull() } else { pending.removeValue(forKey: field) } + } + payload["conflict"] = conflict; payload["pending_plan_change"] = pending + let status: BillingStatus = try accountDecode(payload) + try status.validate() + #expect(status.plan == .paid(.pro) && status.canChat) + let decodedConflict = try #require(status.conflict), decodedPending = try #require(status.pendingPlanChange) + #expect(decodedConflict.otherProvider == (field == "other_provider" ? nil : "apple")) + #expect(decodedConflict.action == (field == "action" ? nil : "future_action")) + #expect(decodedPending.id == (field == "id" ? nil : "fixture-change")) + #expect(decodedPending.type == (field == "type" ? nil : "downgrade")) + #expect(decodedPending.targetPlanName == (field == "target_plan_name" ? nil : "Max")) + #expect(decodedPending.status == (field == "status" ? nil : "pending")) + #expect(decodedPending.expiresAt == (field == "expires_at" ? nil : "2025-01-01T00:00:00Z")) + } + } + + @Test func emptyBillingHintObjectsDoNotInvalidateStatus() throws { + var payload = try #require(JSONSerialization.jsonObject(with: billingBody()) as? [String: Any]) + payload["conflict"] = [String: String](); payload["pending_plan_change"] = [String: String]() + let status: BillingStatus = try accountDecode(payload) + try status.validate() + #expect(status.plan == .paid(.pro) && status.canChat) + let conflict = try #require(status.conflict), pending = try #require(status.pendingPlanChange) + #expect(conflict.otherProvider == nil && conflict.action == nil) + #expect(pending.id == nil && pending.type == nil && pending.targetPlanName == nil) + #expect(pending.status == nil && pending.expiresAt == nil) + } + + @Test func mixedDatesAndFutureManagementValuesStayInert() throws { + let seconds: BillingDate = try JSONDecoder().decode(BillingDate.self, from: Data("1735689600".utf8)) + let iso: BillingDate = try JSONDecoder().decode(BillingDate.self, from: Data(#""2025-01-01T00:00:00Z""#.utf8)) + #expect(seconds.date == iso.date) + #expect(BillingDate.text("1735689600").date == nil) + #expect(BillingDate.text("01/02/2025").date == nil) + #expect(BillingDate.seconds(-1).date == nil) + let subscription: BillingSubscription = try accountDecode([ + "provider": "future", "plan": "Pro", "state": "active", "renews_at": NSNull(), + "manage": "https://untrusted.example", + ]) + #expect(subscription.manage == "https://untrusted.example") + #expect(subscription.provider == "future") + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Account/BillingUsageSummaryTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Account/BillingUsageSummaryTests.swift new file mode 100644 index 000000000..a7b29e2c0 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Account/BillingUsageSummaryTests.swift @@ -0,0 +1,112 @@ +import Foundation +import Testing +@testable import MapleAppCore + +struct BillingUsageSummaryTests { + private let now = Date(timeIntervalSince1970: 1_791_892_800) // 2026-10-13 12:00 UTC. + private var calendar: Calendar { + var value = Calendar(identifier: .gregorian) + value.timeZone = TimeZone(secondsFromGMT: 0)! + return value + } + + private func status( + plan: String? = "Pro", total: Int? = 100, used: Int? = 25, + reset: String? = nil, credits: Double? = nil + ) throws -> BillingStatus { + var body = try #require(JSONSerialization.jsonObject(with: billingBody(plan: plan)) as? [String: Any]) + body["total_tokens"] = total as Any? ?? NSNull() + body["used_tokens"] = used as Any? ?? NSNull() + body["usage_reset_date"] = reset as Any? ?? NSNull() + body["api_credit_balance"] = credits as Any? ?? NSNull() + return try accountDecode(body) + } + + @Test func rawProductNameIsPreservedAndOnlyAStandalonePlanWordSuppressesTheSuffix() throws { + let cases: [(String?, BillingUsageSummary.PlanLabel)] = [ + (nil, .loading), ("", .loading), (" \n ", .loading), + (" Future Premium \n", .productPlan("Future Premium")), + ("Family PLAN", .product("Family PLAN")), ("Plan Flexible", .product("Plan Flexible")), + ("Planet", .productPlan("Planet")), ("Planned Access", .productPlan("Planned Access")), + ("Plan_2", .productPlan("Plan_2")), ("Plané", .product("Plané")), ("计划Plan", .product("计划Plan")), + ("ſPlan", .product("ſPlan")), ("KPlan", .product("KPlan")), + ] + for (name, expected) in cases { + #expect(try status(plan: name).usageSummary().planLabel == expected) + } + } + + @Test func missingUsageAndZeroTotalsHideTheEntireMeterIncludingCredits() throws { + let cases: [(Int?, Int?)] = [(nil, 25), (100, nil), (0, 25), (-1, 25)] + for (total, used) in cases { + #expect(try status(total: total, used: used, credits: 200).usageSummary().meter == nil) + } + } + + @Test func usageClampsAtBothEndsRoundsHalfUpAndDoesNotOverflowLargeCounts() throws { + for (total, used, percent) in [(200, 1, 0.5), (100, -1, 0), (100, 150, 100), (Int.max, Int.max, 100)] { + let meter = try #require(status(total: total, used: used).usageSummary().meter) + #expect(meter.percentUsed == percent) + #expect(meter.roundedPercentUsed == Int(percent.rounded())) + } + } + + @Test func onlyPositiveCreditsAreShownAlongsideAValidMeter() throws { + let balances: [Double?] = [nil, 0, -1, 0.125, 12_345] + for balance in balances { + let meter = try #require(status(credits: balance).usageSummary().meter) + #expect(meter.creditBalance == balance.flatMap { $0 > 0 ? $0 : nil }) + } + } + + @Test func resetLadderUsesTheResetDateRatherThanTheSubscriptionRenewalDate() throws { + let formatter = ISO8601DateFormatter() + let cases: [(TimeInterval, BillingUsageReset)] = [ + (-1, .monthly), (0, .lessThanOneHour), (3_599, .lessThanOneHour), + (3_600, .hours(1)), (3_601, .hours(1)), (3_600 * 2, .hours(2)), + (3_600 * 13, .tomorrow), (86_400 * 2, .days(2)), (86_400 * 7, .days(7)), + (86_400 * 8, .date(now.addingTimeInterval(86_400 * 8))), + ] + for (offset, expected) in cases { + let reset = formatter.string(from: now.addingTimeInterval(offset)) + #expect(try status(reset: reset).usageSummary(now: now, calendar: calendar).meter?.reset == expected) + } + let unreadable: [String?] = [nil, "", "not-a-date", "1792324800"] + for reset in unreadable { + #expect(try status(reset: reset).usageSummary(now: now, calendar: calendar).meter?.reset == .monthly) + } + } + + @Test func nextCalendarDayTakesPrecedenceEvenIfOnlyOneMinuteAway() throws { + let formatter = ISO8601DateFormatter() + let late = try #require(formatter.date(from: "2026-10-13T23:59:00Z")) + let reset = "2026-10-14T00:00:00Z" + #expect(try status(reset: reset).usageSummary(now: late, calendar: calendar).meter?.reset == .tomorrow) + } + + @Test func isoDateOnlyResetValuesUseUTCMidnightAndKeepRenewalDateParsingStrict() throws { + #expect(try status(reset: "2026-10-14").usageSummary(now: now, calendar: calendar).meter?.reset == .tomorrow) + #expect(try status(reset: "2026-10-12").usageSummary(now: now, calendar: calendar).meter?.reset == .monthly) + #expect(BillingDate.text("2026-10-14").date == nil) + #expect( + try status(reset: "2030-04-17bogus").usageSummary(now: now, calendar: calendar).meter?.reset == .monthly) + var local = calendar + local.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + #expect(try status(reset: "2026-10-14").usageSummary(now: now, calendar: local).meter?.reset == .hours(12)) + } + + @Test func calendarDaysRemainCorrectAcrossBothDaylightSavingTransitions() throws { + var local = Calendar(identifier: .gregorian) + local.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + let formatter = ISO8601DateFormatter() + for (start, reset, expected) in [ + ("2026-03-07T23:30:00-08:00", "2026-03-08T23:00:00-07:00", BillingUsageReset.tomorrow), + ("2026-10-31T23:30:00-07:00", "2026-11-01T23:00:00-08:00", BillingUsageReset.tomorrow), + ("2026-03-07T12:00:00-08:00", "2026-03-14T12:00:00-07:00", BillingUsageReset.days(7)), + ("2026-10-31T12:00:00-07:00", "2026-11-07T12:00:00-08:00", BillingUsageReset.days(7)), + ] { + let startDate = try #require(formatter.date(from: start)) + #expect(try status(reset: reset).usageSummary(now: startDate, calendar: local).meter?.reset == expected) + } + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Account/LocalAccountIntegrationTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Account/LocalAccountIntegrationTests.swift new file mode 100644 index 000000000..e7ea50dee --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Account/LocalAccountIntegrationTests.swift @@ -0,0 +1,80 @@ +import Foundation +import MapleSDK +import Synchronization +import Testing +@testable import MapleAppCore + +private final class AccountLiveMemoryStore: SecureStore, Sendable { + private let values = Mutex<[String: Data]>([:]) + func read(key: String) -> Data? { values.withLock { $0[key] } } + func write(_ data: Data, key: String) { values.withLock { $0[key] = data } } + func remove(key: String) { values.withLock { _ = $0.removeValue(forKey: key) } } +} + +/// Explicit opt-in, disposable loopback fixtures only. Provide credentials in the +/// child environment; never pass them as test arguments or use a personal account. +@Suite(.serialized, .enabled(if: ProcessInfo.processInfo.environment["MAPLE_IOS_ACCOUNT_LIVE"] == "1")) +@MainActor struct LocalAccountIntegrationTests { + @Test(arguments: ["PRO", "FREE"]) + func localBillingAndCatalogUseOwnedThirdPartyBearer(_ role: String) async throws { + let environment = ProcessInfo.processInfo.environment + func required(_ name: String) throws -> String { + guard let value = environment[name], !value.isEmpty else { throw AccountServiceError.unavailable } + return value + } + let config = try AppConfiguration( + profile: .local, apiURL: required("MAPLE_IOS_ACCOUNT_API"), + billingURL: required("MAPLE_IOS_ACCOUNT_BILLING"), + flagsURL: required("MAPLE_IOS_ACCOUNT_FLAGS"), authURL: required("MAPLE_IOS_ACCOUNT_AUTH"), + clientID: required("MAPLE_IOS_ACCOUNT_CLIENT_ID"), callbackScheme: "cloud.opensecret.maple.native.dev") + let client = try MapleClient( + apiURL: config.apiURL.absoluteString, clientID: config.clientID, + policy: config.trustPolicy, store: AccountLiveMemoryStore()) + do { + _ = try await client.signIn( + email: required("MAPLE_IOS_ACCOUNT_\(role)_EMAIL"), + password: required("MAPLE_IOS_ACCOUNT_\(role)_PASSWORD")) + let api = try await client.accountAPI() + let user = try await api.getUser() + let scope = AccountScope(apiOrigin: config.apiURL.absoluteString, principalID: user.id) + let billing = BillingService( + scope: scope, configuration: config, tokenSource: SDKBillingTokenSource(api: api)) + let account = AccountEntitlements( + scope: scope, isGuest: false, billing: billing, catalog: SDKModelCatalogSource(api: api)) + await account.refresh() + #expect(account.billing.value != nil) + #expect(account.team.value != nil) + #expect(account.catalog.value != nil) + #expect(!account.primaryModels.isEmpty) + let originalSelection = account.selectedModelID + let gemma = try #require(account.advancedModels.first { $0.id == "gemma4-31b" }) + #expect(gemma.accessTier != "free") + let expectedGemmaAccess: AccountAccess = role == "PRO" ? .allowed : .paidPlanRequired + #expect(account.modelAccess(gemma) == expectedGemmaAccess) + #expect(account.selectModel(gemma.id) == expectedGemmaAccess) + #expect(account.selectedModelID == (role == "PRO" ? gemma.id : originalSelection)) + #expect(account.selectModel(originalSelection) == .allowed) + #expect(account.selectedModelID == originalSelection) + if role == "PRO" { + #expect(account.plan.isPaid) + #expect(account.chatAccess == .allowed) + #expect(account.effectiveWebSearch) + } else { + #expect(account.plan == .free) + #expect(account.effectiveWebSearch) + } + // SDK credential refresh preserves the captured account handle and + // is independent of the third-party token cache. + _ = try await client.refreshCredentials() + let refreshed = try await billing.subscriptionStatus() + #expect(refreshed.plan == account.plan) + await account.retire() + await #expect(throws: AccountServiceError.sessionChanged) { try await billing.subscriptionStatus() } + try await client.signOut() + } catch { + try? await client.signOut() + // Never surface fixture values or raw service diagnostics in test output. + throw BillingService.safe(error) + } + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Account/MediaAccessTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Account/MediaAccessTests.swift new file mode 100644 index 000000000..3bcd00bfd --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Account/MediaAccessTests.swift @@ -0,0 +1,132 @@ +import Foundation +import MapleSDK +import Testing +@testable import MapleAppCore + +@MainActor struct MediaAccessTests { + private func visionCatalog() -> ModelCatalog { + var model = catalogItem(); model.capabilities.vision = true + // Alias capability metadata is deliberately stale; the actual target wins. + return accountCatalog(items: [model, catalogItem("power-target", access: "pro")]) + } + + private func account( + plan: String? = "Pro", guest: Bool = false, canChat: Bool = true, remaining: Int? = nil, + catalog: ModelCatalog? = nil, team: TeamStatus? = nil + ) throws -> AccountEntitlements { + let service = AccountServingStub( + statuses: [.success(try billingStatus(plan: plan, canChat: canChat, remaining: remaining))], + teams: [.success(try team ?? basicTeam())], catalogs: [.success(catalog ?? visionCatalog())]) + return AccountEntitlements(scope: accountScope(), isGuest: guest, billing: service, catalog: service) + } + + @Test func unknownLoadingAndBillingFailureDoNotClaimAFreePlan() async throws { + let gate = AccountGate() + let service = AccountServingStub( + statuses: [.failure(.unavailable)], teams: [.success(try basicTeam())], + catalogs: [.success(visionCatalog())], billingGates: [1: gate]) + let value = AccountEntitlements(scope: accountScope(), isGuest: false, billing: service, catalog: service) + #expect(value.imageAccess == .unavailable && value.transcriptionAccess == .unavailable) + let refresh = Task { await value.refresh() } + await gate.waitUntilEntered() + #expect(value.billing == .loading) + #expect(value.imageAccess == .unavailable && value.transcriptionAccess == .unavailable) + await gate.release(); await refresh.value + #expect(value.billing == .failed(.unavailable)) + #expect(value.imageAccess == .unavailable && value.transcriptionAccess == .unavailable) + #expect(!value.transcriptionAvailable) + } + + @Test func confirmedPlanGatesMediaWhileQuotaAndSeatsRemainServerOwned() async throws { + let cases: [(AccountEntitlements, AccountAccess)] = [ + (try account(plan: "Free"), .paidPlanRequired), + (try account(plan: "Free", guest: true), .freeGuest), + (try account(plan: "Future Premium"), .unknownPlan), + (try account(canChat: false), .allowed), + (try account(remaining: 0), .allowed), + ( + try account( + team: accountDecode([ + "has_team_subscription": true, "team_created": true, "seat_limit_exceeded": true, + ])), .allowed + ), + ] + for (value, reason) in cases { + await value.refresh() + #expect(value.imageAccess == reason) + #expect(value.transcriptionAccess == reason) + #expect(value.transcriptionAvailable == (reason == .allowed)) + } + } + + @Test func imagesAndWebSearchDoNotDependOnSelectedModelCapabilitiesOrCatalog() async throws { + let value = try account(); await value.refresh() + #expect(value.imageAccess == .allowed) + #expect(value.selectedModel?.capabilities.vision == true) + #expect(value.selectModel("auto:powerful") == .allowed) + #expect(value.imageAccess == .allowed) + // Audio does not depend on the text model's vision capability. + #expect(value.transcriptionAccess == .allowed && value.transcriptionAvailable) + + var unknownTier = catalogItem(access: "future"); unknownTier.capabilities.vision = true + let unsupported = try account(catalog: accountCatalog(items: [unknownTier])) + await unsupported.refresh() + #expect(unsupported.chatAccess == .allowed) + #expect(unsupported.imageAccess == .allowed && unsupported.webSearchAccess == .allowed) + #expect(unsupported.transcriptionAccess == .allowed) + + let missing = try account(catalog: accountCatalog(items: [])); await missing.refresh() + #expect(missing.chatAccess == .allowed) + #expect(missing.imageAccess == .allowed && missing.webSearchAccess == .allowed) + } + + @Test func absentAndConfirmedUnavailableTranscriptionHaveDifferentReasons() async throws { + var noAudio = visionCatalog(); noAudio.audio = nil + var noTranscription = visionCatalog(); noTranscription.audio?.transcription = nil + var disabled = visionCatalog() + disabled.audio?.transcription = AudioModelAvailability(available: false, model: "whisper-large-v3") + for catalog in [noAudio, noTranscription] { + let value = try account(catalog: catalog); await value.refresh() + #expect(value.imageAccess == .allowed) + #expect(value.transcriptionAccess == .unavailable && !value.transcriptionAvailable) + } + let value = try account(catalog: disabled); await value.refresh() + #expect(value.transcriptionAccess == .modelIncompatible && !value.transcriptionAvailable) + } + + @Test func catalogFailureOnlyRevokesCatalogOwnedAudioAvailability() async throws { + let failureGate = AccountGate(), retryGate = AccountGate() + let service = AccountServingStub( + statuses: [.success(try billingStatus())], teams: [.success(try basicTeam())], + catalogs: [.success(visionCatalog()), .failure(.unavailable), .success(visionCatalog())], + catalogGates: [2: failureGate, 3: retryGate]) + let value = AccountEntitlements(scope: accountScope(), isGuest: false, billing: service, catalog: service) + await value.refresh() + let failure = Task { await value.refreshCatalog() } + await failureGate.waitUntilEntered() + #expect(value.imageAccess == .allowed && value.transcriptionAccess == .allowed) + await failureGate.release(); await failure.value + #expect(value.webSearchAccess == .allowed) + #expect(value.imageAccess == .allowed && value.transcriptionAccess == .unavailable) + let retry = Task { await value.refreshCatalog() } + await retryGate.waitUntilEntered() + #expect(value.catalog == .loading) + #expect(value.webSearchAccess == .allowed) + #expect(value.imageAccess == .allowed && value.transcriptionAccess == .unavailable) + await retryGate.release(); await retry.value + #expect(value.imageAccess == .allowed && value.transcriptionAccess == .allowed) + } + + @Test func retiredGenerationCannotRegainMediaAccessFromLateCatalog() async throws { + let gate = AccountGate() + let service = AccountServingStub( + statuses: [.success(try billingStatus())], teams: [.success(try basicTeam())], + catalogs: [.success(visionCatalog())], catalogGates: [1: gate]) + let value = AccountEntitlements(scope: accountScope(), isGuest: false, billing: service, catalog: service) + let refresh = Task { await value.refresh() } + await gate.waitUntilEntered() + await value.retire(); await gate.release(); await refresh.value + #expect(value.imageAccess == .retired && value.transcriptionAccess == .retired) + #expect(!value.transcriptionAvailable && value.catalog == .unknown) + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Account/ThinkingLevelTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Account/ThinkingLevelTests.swift new file mode 100644 index 000000000..f5b120444 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Account/ThinkingLevelTests.swift @@ -0,0 +1,99 @@ +import Foundation +import MapleSDK +import Testing +@testable import MapleAppCore + +@MainActor @Suite struct ThinkingLevelTests { + @Test func absentReasoningHidesTheControlAndNeverSendsTheStoredEffort() { + let resolved = AccountEntitlements.resolveThinkingLevel(.high, reasoning: nil) + #expect(resolved.options.isEmpty) + #expect(resolved.effective == .default && resolved.effort == nil) + } + + @Test func optionalReasoningOffersOffAndOrdersDeduplicatedKnownEffortsLowToHigh() { + let reasoning = ModelReasoning( + mandatory: false, defaultEnabled: true, + supportedEfforts: ["max", "high", "low", "none", "minimal", "xhigh", "medium", "low", "future"], + defaultEffort: "high") + let options = AccountEntitlements.thinkingLevelOptions(reasoning) + #expect(options.map(\.value) == [.default, .none, .minimal, .low, .medium, .high, .xhigh, .max]) + #expect(options.first?.defaultEffort == .high) + let off = AccountEntitlements.resolveThinkingLevel(.none, reasoning: reasoning) + #expect(off.effective == .none && off.effort == ReasoningEffort.none) + } + + @Test func mandatoryReasoningNeverOffersOffEvenWhenThePublishedListContainsNone() { + let reasoning = ModelReasoning(mandatory: true, supportedEfforts: ["high", "none", "low"]) + #expect(AccountEntitlements.thinkingLevelOptions(reasoning).map(\.value) == [.default, .low, .high]) + let off = AccountEntitlements.resolveThinkingLevel(.none, reasoning: reasoning) + #expect(off.effective == .default && off.effort == nil) + } + + @Test func untieredReasoningOffersOnlyDefaultAndOptionalOffRegardlessOfDefaultEnabled() { + for defaultEnabled in [false, true] { + let reasoning = ModelReasoning(mandatory: false, defaultEnabled: defaultEnabled) + #expect(AccountEntitlements.thinkingLevelOptions(reasoning).map(\.value) == [.default, .none]) + #expect(AccountEntitlements.resolveThinkingLevel(.low, reasoning: reasoning).effort == nil) + } + } + + @Test func unsupportedStoredChoiceFallsBackWithoutChangingPreferenceWhenModelsSwitch() async throws { + var low = catalogItem("low-model"), high = catalogItem("high-model") + low.reasoning = .init(mandatory: true, supportedEfforts: ["low"], defaultEffort: "low") + high.reasoning = .init(mandatory: true, supportedEfforts: ["high"], defaultEffort: "high") + let service = AccountServingStub( + statuses: [.success(try billingStatus())], teams: [.success(try basicTeam())], + catalogs: [.success(accountCatalog(items: [low, high]))]) + let account = AccountEntitlements( + scope: accountScope(), isGuest: false, billing: service, catalog: service, + selectedModelID: "high-model", thinkingLevel: .high) + #expect(account.resolvedThinkingLevel.effort == nil) + await account.refreshCatalog() + #expect(account.resolvedThinkingLevel.effort == .high) + #expect(account.selectModel("low-model") == .allowed) + #expect(account.resolvedThinkingLevel.effective == .default && account.resolvedThinkingLevel.effort == nil) + #expect(account.thinkingLevel == .high) + #expect(account.selectModel("high-model") == .allowed) + #expect(account.resolvedThinkingLevel.effort == .high) + await account.retire() + #expect(account.resolvedThinkingLevel.options.isEmpty && account.resolvedThinkingLevel.effort == nil) + } + + @Test func aliasReasoningIsAuthoritativeAndMissingAliasMetadataDoesNotInheritTargetEfforts() async throws { + var target = catalogItem() + target.reasoning = .init(mandatory: false, supportedEfforts: ["max"], defaultEffort: "max") + var catalog = accountCatalog(items: [target]) + catalog.aliases[0].reasoning = .init(mandatory: true, supportedEfforts: ["low"], defaultEffort: "low") + let withoutAliasReasoning = accountCatalog(items: [target]) + let service = AccountServingStub( + statuses: [.success(try billingStatus())], teams: [.success(try basicTeam())], + catalogs: [.success(catalog), .success(withoutAliasReasoning), .failure(.unavailable)]) + let account = AccountEntitlements( + scope: accountScope(), isGuest: false, billing: service, catalog: service, thinkingLevel: .max) + await account.refreshCatalog() + #expect(account.resolvedThinkingLevel.options.map(\.value) == [.default, .low]) + #expect(account.resolvedThinkingLevel.effort == nil && account.thinkingLevel == .max) + account.thinkingLevel = .low + #expect(account.resolvedThinkingLevel.effort == .low) + await account.refreshCatalog() + #expect(account.resolvedThinkingLevel.options.isEmpty && account.resolvedThinkingLevel.effort == nil) + await account.refreshCatalog() + #expect(account.catalog == .failed(.unavailable)) + #expect(account.resolvedThinkingLevel.effort == nil && account.chatAccess == .allowed) + } + + @Test func queuedAndPendingTurnsRetainTheirOwnEffortsAcrossSnapshotRestart() throws { + let waiting = ChatQueuedTurn(draft: .init(text: "Queued synthetic"), options: .init(reasoningEffort: .high)) + let pending = ChatPendingRun( + id: UUID(), messageID: "message", outgoingDraft: .init(text: "Active synthetic"), + options: .init(reasoningEffort: .low)) + let snapshot = ChatSnapshot(pendingRun: pending, queue: .init(items: [waiting])) + let decoded = try JSONDecoder().decode(ChatSnapshot.self, from: JSONEncoder().encode(snapshot)) + #expect(decoded == snapshot) + #expect(decoded.pendingRun?.options.reasoningEffort == .low) + #expect(decoded.queue?.items.first?.options.reasoningEffort == .high) + let legacy = try JSONDecoder().decode( + ChatSendOptions.self, from: Data(#"{"model":"auto:quick","webSearch":true}"#.utf8)) + #expect(legacy.webSearch && legacy.reasoningEffort == nil) + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Audio/DictationSendAssociationTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Audio/DictationSendAssociationTests.swift new file mode 100644 index 000000000..5d68cb829 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Audio/DictationSendAssociationTests.swift @@ -0,0 +1,88 @@ +import Foundation +import Testing +@testable import MapleAppCore + +struct DictationSendAssociationTests { + private func owner(scope: AccountScope = accountScope(), storage: UUID = UUID(), draft: UUID = UUID()) + -> DictationDraftOwner + { + DictationDraftOwner(scope: scope, storageOwnerID: storage, draftID: draft) + } + private func accepted(_ owner: DictationDraftOwner, chat: String? = "chat", message: String = "turn") + -> ChatSendAcceptance + { + ChatSendAcceptance( + scope: owner.scope, storageOwnerID: owner.storageOwnerID, draftID: owner.draftID, + messageID: message, conversationID: chat) + } + + @Test func anotherChatOrHeldComposerCannotAdoptReview() throws { + var gate = DictationSendAssociation() + let dictated = owner(), otherChat = owner(scope: dictated.scope), + held = owner(scope: dictated.scope, storage: dictated.storageOwnerID) + gate.captureAdmitted(dictated) + #expect(gate.ticket(for: otherChat, reviewActive: true) == nil) + #expect(gate.ticket(for: held, reviewActive: true) == nil) + #expect(gate.ticket(for: dictated, reviewActive: false) == nil) + let ticket = try #require(gate.ticket(for: dictated, reviewActive: true)) + gate.didSubmit(ticket) + let unrelated = gate.accepted(accepted(otherChat), reviewActive: true) + #expect(unrelated == nil) + let own = gate.accepted(accepted(dictated), reviewActive: true) + #expect(own?.chatID == "chat" && own?.messageID == "turn") + let replay = gate.accepted(accepted(dictated), reviewActive: true) + #expect(replay == nil) + } + + @Test func onlyDurablyAcceptedExplicitSendCanArmTheTurn() throws { + var gate = DictationSendAssociation() + let draft = owner(); gate.captureAdmitted(draft) + let ticket = try #require(gate.ticket(for: draft, reviewActive: true)) + let unsent = gate.accepted(accepted(draft), reviewActive: true) + #expect(unsent == nil) + gate.didSubmit(ticket) + let noLongerReviewing = gate.accepted(accepted(draft), reviewActive: false) + #expect(noLongerReviewing == nil) + gate.captureAdmitted(draft) + gate.didSubmit(ticket) // A new permission request invalidated this lease. + let stale = gate.accepted(accepted(draft), reviewActive: true) + #expect(stale == nil) + } + + @Test func newChatCreationKeepsStableOwnerAndRejectsLateAssignment() throws { + var gate = DictationSendAssociation() + let draft = owner(); gate.captureAdmitted(draft) + let ticket = try #require(gate.ticket(for: draft, reviewActive: true)); gate.didSubmit(ticket) + let waiting = gate.accepted(accepted(draft, chat: nil), reviewActive: true) + #expect(waiting == nil) + let other = gate.conversationAssigned( + scope: draft.scope, storageOwnerID: UUID(), chatID: "unrelated", reviewActive: true) + #expect(other == nil) + let assigned = gate.conversationAssigned( + scope: draft.scope, storageOwnerID: draft.storageOwnerID, chatID: "created", reviewActive: true) + #expect(assigned?.chatID == "created" && assigned?.messageID == "turn") + + gate.captureAdmitted(draft) + let next = try #require(gate.ticket(for: draft, reviewActive: true)); gate.didSubmit(next) + _ = gate.accepted(accepted(draft, chat: nil), reviewActive: true) + gate.captureAdmitted(owner(scope: draft.scope)) + let late = gate.conversationAssigned( + scope: draft.scope, storageOwnerID: draft.storageOwnerID, chatID: "old", reviewActive: true) + #expect(late == nil) + } + + @Test func retirementAndSamePrincipalReplacementCannotAdoptOldReview() throws { + var gate = DictationSendAssociation() + let draft = owner(); gate.captureAdmitted(draft) + let ticket = try #require(gate.ticket(for: draft, reviewActive: true)); gate.didSubmit(ticket) + let replacementScope = AccountScope(apiOrigin: draft.scope.apiOrigin, principalID: draft.scope.principalID) + let replacement = owner(scope: replacementScope, storage: draft.storageOwnerID, draft: draft.draftID) + let otherGeneration = gate.accepted(accepted(replacement), reviewActive: true) + #expect(otherGeneration == nil) + gate.retire(draft) + let retired = gate.accepted(accepted(draft), reviewActive: true) + #expect(retired == nil) + gate.captureAdmitted(draft); gate.reset() + #expect(gate.ticket(for: draft, reviewActive: true) == nil) + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Audio/ListeningFixtures.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Audio/ListeningFixtures.swift new file mode 100644 index 000000000..36ba6889a --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Audio/ListeningFixtures.swift @@ -0,0 +1,74 @@ +import Foundation +import Testing +@testable import MapleAppCore + +actor ListeningPreparer: SpeechPreparing { + struct Request: Sendable { let reply: SpeechReply; let voice: MapleVoice; let owner: SpeechOwner } + var requests: [Request] = [] + var discarded: [PreparedSpeech] = [] + var retired: [SpeechOwner] = [] + var gate: ChatTestGate? + var failRetirement = false + private var discardWaiters: [CheckedContinuation] = [] + func hold(_ gate: ChatTestGate?) { self.gate = gate } + func failRetire(_ value: Bool) { failRetirement = value } + func prepare(_ reply: SpeechReply, voice: MapleVoice, owner: SpeechOwner) async throws -> PreparedSpeech { + requests.append(Request(reply: reply, voice: voice, owner: owner)) + if let gate { await gate.hold() } + return PreparedSpeech( + owner: owner, responseID: reply.responseID, + chunks: [ + SpeechChunk( + file: StoredSpeechFile( + id: UUID(), url: URL(fileURLWithPath: "/synthetic/speech.wav"), byteCount: 40), duration: 30) + ]) + } + func discard(_ speech: PreparedSpeech) { + discarded.append(speech); discardWaiters.forEach { $0.resume() }; discardWaiters = [] + } + func waitForDiscard() async { + if discarded.isEmpty { await withCheckedContinuation { discardWaiters.append($0) } } + } + func retire(_ owner: SpeechOwner) throws { + if failRetirement { throw SpeechIssue.storage } + retired.append(owner) + } +} + +@MainActor final class ListeningPlayer: SpeechPlaying { + var onEvent: (@MainActor (SpeechPlayerEvent) -> Void)? + var position: TimeInterval = 0 + var duration: TimeInterval = 0 + var speed: MaplePlaybackSpeed = .normal + var playCount = 0 + var pauseCount = 0 + var loads: [String] = [] + var currentID: UUID? + var isPlaying = false + func load(_ speech: PreparedSpeech, playbackID: UUID) { + duration = speech.duration; position = 0; currentID = playbackID; loads.append(speech.responseID) + } + func play() { playCount += 1; isPlaying = true } + func pause() { pauseCount += 1; isPlaying = false } + func stop() { isPlaying = false; position = 0; duration = 0; currentID = nil } + func seek(to position: TimeInterval) { self.position = min(duration, max(0, position)) } + func setSpeed(_ speed: MaplePlaybackSpeed) { self.speed = speed } + func finish() { + guard let currentID else { return }; isPlaying = false; position = duration; onEvent?(.ended(currentID)) + } +} + +@MainActor final class ListeningAdmission { + var issue: SpeechIssue? +} + +func speechReply(_ id: String, chat: String = "chat", turn: String? = nil) -> SpeechReply { + SpeechReply(chatID: chat, responseID: id, text: "Readable reply \(id)", turnID: turn) +} + +@MainActor func listeningFixture() -> (ReplyListeningSession, ListeningPreparer, ListeningPlayer) { + let preparer = ListeningPreparer(), player = ListeningPlayer() + let session = ReplyListeningSession(scope: accountScope(), preparer: preparer, player: player) + session.setVisibility(chatID: "chat", foreground: true) + return (session, preparer, player) +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Audio/ListeningObservationTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Audio/ListeningObservationTests.swift new file mode 100644 index 000000000..533d84d04 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Audio/ListeningObservationTests.swift @@ -0,0 +1,69 @@ +import Foundation +import Observation +import Synchronization +import Testing +@testable import MapleAppCore + +private final class ListeningObservationChanges: Sendable { + private let count = Mutex(0) + var value: Int { count.withLock { $0 } } + func record() { count.withLock { $0 += 1 } } +} + +private actor FailingPresentationPreparer: SpeechPreparing { + func prepare(_ reply: SpeechReply, voice: MapleVoice, owner: SpeechOwner) throws -> PreparedSpeech { + throw SpeechIssue.invalidAudio + } + func discard(_ speech: PreparedSpeech) {} + func retire(_ owner: SpeechOwner) {} +} + +@MainActor struct ListeningObservationTests { + @Test func closedPlayerObservesPresentationBeforeItReadsPlaybackState() async throws { + let (session, _, _) = listeningFixture() + let opening = ListeningObservationChanges() + let initiallyPresented = withObservationTracking { + // The closed SwiftUI branch cannot observe phase or current yet. + session.isPresented + } onChange: { + opening.record() + } + #expect(!initiallyPresented) + session.playReply(speechReply("manual")) + #expect(opening.value == 1) + #expect(session.isPresented && session.phase == .preparing) + await session.waitForPreparation() + #expect(session.phase == .playing) + + let closing = ListeningObservationChanges() + let presented = withObservationTracking { + session.isPresented + } onChange: { + closing.record() + } + #expect(presented) + session.close() + #expect(closing.value == 1) + #expect(!session.isPresented && session.phase == .closed) + try await session.waitForCleanup() + } + + @Test func preparationFailureStillPublishesAnActionablePlayer() async throws { + let session = ReplyListeningSession( + scope: accountScope(), preparer: FailingPresentationPreparer(), player: ListeningPlayer()) + let changes = ListeningObservationChanges() + _ = withObservationTracking { + session.isPresented + } onChange: { + changes.record() + } + session.playReply(speechReply("invalid-audio")) + await session.waitForPreparation() + #expect(changes.value == 1) + #expect(session.isPresented) + #expect(session.phase == .failed(.invalidAudio) && session.issue == .invalidAudio) + #expect(session.current?.responseID == "invalid-audio") + session.close() + try await session.waitForCleanup() + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Audio/ListeningPreferencesTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Audio/ListeningPreferencesTests.swift new file mode 100644 index 000000000..7a5948b02 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Audio/ListeningPreferencesTests.swift @@ -0,0 +1,43 @@ +import Foundation +import Testing +@testable import MapleAppCore + +@MainActor @Suite struct ListeningPreferencesTests { + @Test func recreatedPreferencesAndListeningSessionKeepSpeedButNeverAutoplay() async throws { + let suite = "listening-preferences-tests.\(UUID().uuidString)" + defer { UserDefaults.standard.removePersistentDomain(forName: suite) } + let oldScope = accountScope() + let originalPreferences = PreferencesController(scope: oldScope, store: PreferencesStore(suiteName: suite)) + await originalPreferences.load() + let original = ReplyListeningSession( + scope: oldScope, preparer: ListeningPreparer(), player: ListeningPlayer()) + original.onSpeedChanged = { [weak originalPreferences] speed in originalPreferences?.setPlaybackSpeed(speed) } + original.setAutoplay(true, chatID: "chat") + original.setSpeed(.fast) + await originalPreferences.flush() + #expect(originalPreferences.saveIssue == nil && original.autoplayEnabled(chatID: "chat")) + try await original.invalidate(); try await originalPreferences.retire() + + let newScope = AccountScope(apiOrigin: oldScope.apiOrigin, principalID: oldScope.principalID) + let loaded = PreferencesController(scope: newScope, store: PreferencesStore(suiteName: suite)) + await loaded.load() + #expect(loaded.state == .ready && loaded.value.playbackSpeed == .fast) + let preparer = ListeningPreparer(), player = ListeningPlayer() + let fresh = ReplyListeningSession( + scope: newScope, preparer: preparer, player: player, + voice: loaded.value.voice, speed: loaded.value.playbackSpeed) + fresh.setVisibility(chatID: "chat", foreground: true) + #expect(!fresh.isPresented && !fresh.autoplayEnabled(chatID: "chat")) + fresh.completed(speechReply("ignored"), wasVisible: true, wasForeground: true) + #expect(await preparer.requests.isEmpty) + fresh.playReply(speechReply("manual")); await fresh.waitForPreparation() + #expect(player.speed == .fast && player.loads == ["manual"]) + fresh.close() + fresh.setAutoplay(true, chatID: "chat") + fresh.completed(speechReply("automatic"), wasVisible: true, wasForeground: true) + await fresh.waitForPreparation() + #expect(player.speed == .fast && player.loads == ["manual", "automatic"]) + #expect(fresh.phase == .playing) + try await fresh.invalidate(); try await loaded.retire() + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Audio/ListeningTransitionTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Audio/ListeningTransitionTests.swift new file mode 100644 index 000000000..383897fb6 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Audio/ListeningTransitionTests.swift @@ -0,0 +1,255 @@ +import Foundation +import Testing +@testable import MapleAppCore + +@MainActor @Suite struct ListeningTransitionTests { + @Test func speakerUsesSavedVoiceAndSpeedWithoutEnablingAutoplay() async { + let (session, preparer, player) = listeningFixture() + session.updatePreferences(voice: .frenchMale, speed: .fast) + session.playReply(speechReply("one")); await session.waitForPreparation() + #expect(session.phase == .playing && player.speed == .fast) + #expect(!session.autoplayEnabled(chatID: "chat")) + #expect(await preparer.requests.first?.voice == .frenchMale) + var saved: MaplePlaybackSpeed? + session.onSpeedChanged = { saved = $0 } + session.setSpeed(.double) + #expect(saved == .double && player.speed == .double) + session.playReply(speechReply("two", chat: "other")); await session.waitForPreparation() + #expect(player.loads == ["one", "two"] && player.speed == .double) + #expect(await preparer.retired.count == 1) + } + + @Test func offscreenCompletionAndReturningNeverStartCatchUpAudio() async { + let (session, _, player) = listeningFixture() + session.setAutoplay(true, chatID: "chat") + session.setVisibility(chatID: "other", foreground: true) + session.completed(speechReply("missed"), wasVisible: false, wasForeground: true) + session.setVisibility(chatID: "chat", foreground: true) + #expect(session.pendingCount == 0 && !session.isPresented && player.playCount == 0) + session.completed(speechReply("now"), wasVisible: true, wasForeground: true) + await session.waitForPreparation() + #expect(player.loads == ["now"] && player.playCount == 1) + session.completed(speechReply("recovered"), wasVisible: true, wasForeground: true, recovered: true) + session.completed(speechReply("stopped"), wasVisible: true, wasForeground: true, wasStopped: true) + #expect(session.pendingCount == 0) + } + + @Test func navigationKeepsCurrentPlayingButBlocksAutomaticAdvancement() async { + let (session, _, player) = listeningFixture() + session.setAutoplay(true, chatID: "chat") + session.playReply(speechReply("one")); await session.waitForPreparation() + session.completed(speechReply("two"), wasVisible: true, wasForeground: true) + session.setVisibility(chatID: "other", foreground: false) + #expect(player.isPlaying) + player.finish(); #expect(session.phase == .ended && session.pendingCount == 1) + session.setVisibility(chatID: "chat", foreground: true) + #expect(player.playCount == 1 && session.phase == .ended) + session.setVisibility(chatID: "other", foreground: false) + #expect(session.play()); await session.waitForPreparation() + #expect(player.loads == ["one", "two"] && player.isPlaying) + } + + @Test func manualPauseAndRoutePauseRetainFIFOUntilExplicitPlay() async { + let (session, _, player) = listeningFixture() + session.setAutoplay(true, chatID: "chat") + session.playReply(speechReply("one")); await session.waitForPreparation() + session.pause() + session.completed(speechReply("two"), wasVisible: true, wasForeground: true) + session.completed(speechReply("three"), wasVisible: true, wasForeground: true) + session.routeDisconnected(); session.routeReconnected() + #expect(player.playCount == 1 && session.manualPaused && session.routePaused) + #expect(session.pending.map(\.responseID) == ["two", "three"]) + #expect(session.play()); #expect(!session.manualPaused && !session.routePaused) + player.finish(); await session.waitForPreparation() + #expect(player.loads == ["one", "two"] && session.pending.map(\.responseID) == ["three"]) + } + + @Test func captureAndTranscriptionBlockPlayAndReviewRequiresExplicitResume() async { + let (session, _, player) = listeningFixture() + session.playReply(speechReply("one")); await session.waitForPreparation() + session.startDictation(); #expect(!session.play() && !player.isPlaying) + session.transcriptionStarted(); #expect(!session.play()) + session.dictationFinishedOrCancelled() + #expect(session.dictationHold == .review && player.playCount == 1) + #expect(session.play()); #expect(player.playCount == 2 && session.dictationHold == .none) + } + + @Test func manualPauseSurvivesDictationAndSendWithoutSkippingPendingReplies() async { + let (session, _, player) = listeningFixture() + session.setAutoplay(true, chatID: "chat") + session.playReply(speechReply("one")); await session.waitForPreparation() + session.pause(); session.startDictation(); session.dictationFinishedOrCancelled() + session.completed(speechReply("two"), wasVisible: true, wasForeground: true) + session.sentAfterDictation(chatID: "chat", turnID: "new-turn") + session.completed(speechReply("new", turn: "new-turn"), wasVisible: true, wasForeground: true) + #expect(session.manualPaused && player.playCount == 1 && session.current?.responseID == "one") + #expect(session.pending.map(\.responseID) == ["two", "new"]) + #expect(session.play()); player.finish(); await session.waitForPreparation() + #expect(session.current?.responseID == "two") + } + + @Test func soleReviewHoldArmsOnlyTheExplicitlySentTurnForReplacement() async { + let (session, preparer, player) = listeningFixture() + session.setAutoplay(true, chatID: "chat") + session.playReply(speechReply("old")); await session.waitForPreparation() + session.startDictation(); session.dictationFinishedOrCancelled() + session.sentAfterDictation(chatID: "chat", turnID: "armed") + #expect(player.playCount == 1 && !player.isPlaying && session.current?.responseID == "old") + session.completed(speechReply("unrelated", turn: "not-armed"), wasVisible: true, wasForeground: true) + #expect(session.current?.responseID == "old" && player.playCount == 1) + session.completed(speechReply("new", turn: "armed"), wasVisible: true, wasForeground: true) + await session.waitForPreparation() + #expect(session.current?.responseID == "new" && player.playCount == 2) + #expect(session.pending.isEmpty) + #expect(await preparer.retired.count == 1) + } + + @Test func explicitResumeCancelsDictationReplacementArm() async { + let (session, _, player) = listeningFixture() + session.setAutoplay(true, chatID: "chat") + session.playReply(speechReply("old")); await session.waitForPreparation() + session.startDictation(); session.dictationFinishedOrCancelled() + session.sentAfterDictation(chatID: "chat", turnID: "armed") + #expect(session.play()) + session.completed(speechReply("new", turn: "armed"), wasVisible: true, wasForeground: true) + #expect(session.current?.responseID == "old" && session.pending.map(\.responseID) == ["new"]) + #expect(player.playCount == 2) + } + + @Test func closeDisablesAutoplayAndRejectsLatePreparationAndPlayerEvents() async throws { + let (session, preparer, player) = listeningFixture(), gate = ChatTestGate() + session.setAutoplay(true, chatID: "chat"); await preparer.hold(gate) + session.playReply(speechReply("one")) + await gate.waitForArrival(); session.close(); await gate.release() + try await session.waitForCleanup() + await preparer.waitForDiscard() + #expect(!session.isPresented && !session.autoplayEnabled(chatID: "chat") && player.playCount == 0) + #expect(await preparer.discarded.count == 1) + } + + @Test func preparationCannotAutomaticallyStartAfterLeavingItsChat() async { + let (session, preparer, player) = listeningFixture(), gate = ChatTestGate() + session.setAutoplay(true, chatID: "chat"); await preparer.hold(gate) + session.completed(speechReply("one"), wasVisible: true, wasForeground: true) + await gate.waitForArrival(); session.setVisibility(chatID: "other", foreground: false) + await gate.release(); await session.waitForPreparation() + #expect(session.phase == .paused && player.playCount == 0) + session.setVisibility(chatID: "chat", foreground: true) + #expect(player.playCount == 0) + } + + @Test func pendingCapacityIsExplicitAndSkipsClampWithoutResumingPause() async { + let (session, _, player) = listeningFixture() + session.setAutoplay(true, chatID: "chat") + session.playReply(speechReply("one")); await session.waitForPreparation(); session.pause() + for id in 0..<17 { session.completed(speechReply("pending-\(id)"), wasVisible: true, wasForeground: true) } + #expect(session.pendingCount == 16 && session.issue == .capacity) + session.skip(seconds: -10); #expect(session.position == 0) + session.skip(seconds: 1_000); #expect(session.position == 30) + #expect(session.phase == .ended && player.playCount == 1 && session.manualPaused) + } + + @Test func retirementClearsAutoplayAndIgnoresOldEndedCallback() async throws { + let (session, _, player) = listeningFixture() + session.setAutoplay(true, chatID: "chat") + session.playReply(speechReply("one")); await session.waitForPreparation() + let old = try #require(player.currentID), callback = player.onEvent + try await session.invalidate() + callback?(.ended(old)) + session.completed(speechReply("late"), wasVisible: true, wasForeground: true) + #expect(session.phase == .closed && !session.isPresented && session.autoplayChats.isEmpty) + #expect(player.playCount == 1) + } + @Test func cleanupFailureStaysVisibleAndRetryClearsExactRetiredOwner() async throws { + let (session, preparer, _) = listeningFixture() + session.playReply(speechReply("one")); await session.waitForPreparation() + await preparer.failRetire(true) + session.close() + await #expect(throws: SpeechIssue.storage) { try await session.waitForCleanup() } + #expect(session.cleanupRequired && !session.isPresented) + await preparer.failRetire(false) + try await session.retryCleanup() + #expect(!session.cleanupRequired) + #expect(await preparer.retired.count == 1) + } + @Test func systemResumeRequiresRealInterruptionPermissionAndNeverOverridesManualPause() async { + let (session, _, player) = listeningFixture() + session.playReply(speechReply("one")); await session.waitForPreparation() + session.interruptionBegan(); session.interruptionEnded(shouldResume: false) + #expect(player.playCount == 1 && session.routePaused) + #expect(session.play()) + session.interruptionBegan(); session.interruptionEnded(shouldResume: true) + #expect(player.playCount == 3 && !session.routePaused) + session.interruptionBegan(); session.pause(); session.interruptionEnded(shouldResume: true) + #expect(player.playCount == 3 && session.manualPaused) + } + @Test func staleRemoteCommandCannotControlReplacementSession() async throws { + let (session, _, player) = listeningFixture() + session.playReply(speechReply("one")); await session.waitForPreparation() + let old = try #require(session.sessionID) + session.playReply(speechReply("two")); await session.waitForPreparation() + #expect(!session.remote(.pause, sessionID: old) && player.isPlaying) + let current = try #require(session.sessionID) + #expect(session.remote(.forwardTenSeconds, sessionID: current) && session.position == 10) + #expect(session.remote(.pause, sessionID: current) && !player.isPlaying) + } + @Test func typedCompletionFromRetiredSamePrincipalGenerationIsIgnored() async { + let (session, _, player) = listeningFixture() + session.setAutoplay(true, chatID: "chat") + let old = AccountScope(apiOrigin: session.scope.apiOrigin, principalID: session.scope.principalID) + let completion = ChatCompletion( + scope: old, runID: UUID(), conversationID: "chat", messageID: UUID().uuidString, + responseID: "late", assistantItems: [], recovered: false, wasStopped: false, + wasVisible: true, wasForeground: true) + session.completed(completion) + #expect(!session.isPresented && player.playCount == 0) + } + @Test func admissionRefreshNeverInterruptsBufferedPlaybackOrDropsPendingFIFO() async { + let preparer = ListeningPreparer(), player = ListeningPlayer() + let admission = ListeningAdmission() + let session = ReplyListeningSession( + scope: accountScope(), preparer: preparer, player: player, admission: { admission.issue }) + session.setVisibility(chatID: "chat", foreground: true); session.setAutoplay(true, chatID: "chat") + session.playReply(speechReply("one")); await session.waitForPreparation() + session.completed(speechReply("two"), wasVisible: true, wasForeground: true) + admission.issue = .unavailable + session.pause(); #expect(session.play() && player.isPlaying) + player.finish() + #expect(session.phase == .ended && session.pending.map(\.responseID) == ["two"]) + #expect(session.issue == .unavailable && !session.play()) + #expect(await preparer.requests.count == 1) + admission.issue = nil + #expect(session.play()); await session.waitForPreparation() + #expect(session.current?.responseID == "two" && player.isPlaying) + } + @Test func backwardSeekAfterEndMakesCurrentAudioResumableWithoutNewSynthesis() async { + let (session, preparer, player) = listeningFixture() + session.playReply(speechReply("one")); await session.waitForPreparation() + player.finish(); #expect(session.phase == .ended) + session.skip(seconds: -10) + #expect(session.phase == .paused && session.position == 20) + #expect(session.play() && player.isPlaying) + #expect(await preparer.requests.count == 1) + } + @Test func seekToEndWhilePausedAllowsExplicitOffscreenPlayOfAlreadyPendingReply() async { + let (session, _, player) = listeningFixture() + session.setAutoplay(true, chatID: "chat") + session.playReply(speechReply("one")); await session.waitForPreparation() + session.completed(speechReply("two"), wasVisible: true, wasForeground: true) + session.pause(); session.skip(seconds: 1_000) + #expect(session.phase == .ended && session.manualPaused && !player.isPlaying) + session.setVisibility(chatID: "other", foreground: false) + #expect(session.play()); await session.waitForPreparation() + #expect(session.current?.responseID == "two" && player.isPlaying) + } + @Test func transcriptionRetryReacquiresHoldAfterExplicitReviewResume() async { + let (session, _, player) = listeningFixture() + session.playReply(speechReply("one")); await session.waitForPreparation() + session.startDictation(); session.dictationFinishedOrCancelled() + #expect(session.play() && player.isPlaying) + session.transcriptionStarted() + #expect(session.dictationHold == .transcription && !player.isPlaying && !session.play()) + session.dictationFinishedOrCancelled() + #expect(session.dictationHold == .review && player.playCount == 2) + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Audio/SpeechPreparerTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Audio/SpeechPreparerTests.swift new file mode 100644 index 000000000..3e1a02266 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Audio/SpeechPreparerTests.swift @@ -0,0 +1,129 @@ +import Foundation +import MapleSDK +import Testing +@testable import MapleAppCore + +private actor SynthesisFixture: SpeechSynthesizing { + var requests: [(String, MapleVoice)] = [] + var mime = "audio/wav" + var failAt: Int? + var gate: ChatTestGate? + func configure(mime: String = "audio/wav", failAt: Int? = nil, gate: ChatTestGate? = nil) { + self.mime = mime; self.failAt = failAt; self.gate = gate + } + func synthesize(text: String, voice: MapleVoice) async throws -> MapleResponse { + requests.append((text, voice)) + if let gate { await gate.hold() } + if requests.count == failAt { throw ResearchAPIError.http(status: 503, reason: .unspecified) } + return MapleResponse( + status: 200, headers: ["Content-Type": mime], + body: AsyncThrowingStream { + $0.yield(Data("synthetic-wave".utf8)); $0.finish() + }) + } +} +private actor SpeechInspectorFixture: SpeechMediaInspecting { + let seconds: Double + init(seconds: Double = 1) { self.seconds = seconds } + func duration(of file: StoredSpeechFile) -> TimeInterval { seconds } +} +private actor SpeechAdmissionFixture { + var requests = 0 + func check() -> SpeechIssue? { + requests += 1 + return requests > 1 ? .paidPlanRequired : nil + } +} + +@Suite struct SpeechPreparerTests { + private func directory() -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent("speech-prepare-\(UUID().uuidString)") + } + + @Test func entireReplyIsPreparedSequentiallyWithExplicitVoiceBeforePlay() async throws { + let root = directory(); defer { try? FileManager.default.removeItem(at: root) } + let files = TemporarySpeechStore(root: root), synth = SynthesisFixture() + try await files.startupCleanup() + let preparer = SpeechPreparer(synthesis: synth, files: files, inspector: SpeechInspectorFixture()) + let owner = SpeechOwner(account: accountScope()) + let result = try await preparer.prepare( + SpeechReply(chatID: "chat", responseID: "reply", text: String(repeating: "word ", count: 601)), + voice: .casualFemale, owner: owner) + #expect(result.chunks.count == 3 && result.duration == 3) + #expect(await synth.requests.map { $0.0.split(separator: " ").count } == [300, 300, 1]) + #expect(await synth.requests.allSatisfy { $0.1 == .casualFemale }) + try await preparer.retire(owner) + #expect(try FileManager.default.contentsOfDirectory(atPath: root.path).isEmpty) + } + @Test func providerJSONAndUndecodableContentAreSanitizedAndRemoved() async throws { + let root = directory(); defer { try? FileManager.default.removeItem(at: root) } + let files = TemporarySpeechStore(root: root), synth = SynthesisFixture() + try await files.startupCleanup(); await synth.configure(mime: "audio/error+json") + let preparer = SpeechPreparer(synthesis: synth, files: files, inspector: SpeechInspectorFixture()) + let owner = SpeechOwner(account: accountScope()) + await #expect(throws: SpeechIssue.invalidAudio) { + try await preparer.prepare(speechReply("one"), voice: .neutralMale, owner: owner) + } + #expect(await files.allocatedBytes == 0) + await synth.configure() + let invalid = SpeechPreparer(synthesis: synth, files: files, inspector: SpeechInspectorFixture(seconds: .nan)) + await #expect(throws: SpeechIssue.invalidAudio) { + try await invalid.prepare(speechReply("two"), voice: .neutralMale, owner: owner) + } + #expect(await files.allocatedBytes == 0) + } + @Test func partialProviderFailureCleansCompletedChunksWithoutReturningTruncatedReply() async throws { + let root = directory(); defer { try? FileManager.default.removeItem(at: root) } + let files = TemporarySpeechStore(root: root), synth = SynthesisFixture() + try await files.startupCleanup(); await synth.configure(failAt: 2) + let preparer = SpeechPreparer(synthesis: synth, files: files, inspector: SpeechInspectorFixture()) + let owner = SpeechOwner(account: accountScope()) + await #expect(throws: SpeechIssue.unavailable) { + try await preparer.prepare( + SpeechReply(chatID: "chat", responseID: "reply", text: String(repeating: "word ", count: 350)), + voice: .casualFemale, owner: owner) + } + #expect(await files.allocatedBytes == 0) + #expect(try FileManager.default.contentsOfDirectory(atPath: root.path).isEmpty) + } + @Test func retiredSessionRejectsLateSynthesisBeforeAnyFileWrite() async throws { + let root = directory(); defer { try? FileManager.default.removeItem(at: root) } + let files = TemporarySpeechStore(root: root), synth = SynthesisFixture(), gate = ChatTestGate() + try await files.startupCleanup(); await synth.configure(gate: gate) + let preparer = SpeechPreparer(synthesis: synth, files: files, inspector: SpeechInspectorFixture()) + let owner = SpeechOwner(account: accountScope()) + let work = Task { try await preparer.prepare(speechReply("one"), voice: .casualFemale, owner: owner) } + await gate.waitForArrival(); try await preparer.retire(owner); await gate.release() + await #expect(throws: SpeechIssue.retired) { try await work.value } + #expect(await files.allocatedBytes == 0) + #expect(try FileManager.default.contentsOfDirectory(atPath: root.path).isEmpty) + } + @Test func emptySpeechDoesNotContactProvider() async throws { + let root = directory(); defer { try? FileManager.default.removeItem(at: root) } + let files = TemporarySpeechStore(root: root), synth = SynthesisFixture() + try await files.startupCleanup() + let preparer = SpeechPreparer(synthesis: synth, files: files, inspector: SpeechInspectorFixture()) + await #expect(throws: SpeechIssue.emptyText) { + try await preparer.prepare( + SpeechReply(chatID: "chat", responseID: "empty", text: "```\ncode\n```"), voice: .casualFemale, + owner: SpeechOwner(account: accountScope())) + } + #expect(await synth.requests.isEmpty) + } + @Test func everyChunkRechecksAdmissionAndFailureRemovesAlreadyPreparedFiles() async throws { + let root = directory(); defer { try? FileManager.default.removeItem(at: root) } + let files = TemporarySpeechStore(root: root), synth = SynthesisFixture(), admission = SpeechAdmissionFixture() + try await files.startupCleanup() + let preparer = SpeechPreparer( + synthesis: synth, files: files, inspector: SpeechInspectorFixture(), + admission: { await admission.check() }) + await #expect(throws: SpeechIssue.paidPlanRequired) { + try await preparer.prepare( + SpeechReply(chatID: "chat", responseID: "reply", text: String(repeating: "word ", count: 350)), + voice: .casualFemale, owner: SpeechOwner(account: accountScope())) + } + #expect(await synth.requests.count == 1) + #expect(await files.allocatedBytes == 0) + #expect(try FileManager.default.contentsOfDirectory(atPath: root.path).isEmpty) + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Audio/SpeechTextPlannerTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Audio/SpeechTextPlannerTests.swift new file mode 100644 index 000000000..586532191 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Audio/SpeechTextPlannerTests.swift @@ -0,0 +1,80 @@ +import Foundation +import Testing +@testable import MapleAppCore + +@Suite struct SpeechTextPlannerTests { + @Test(arguments: [ + ( + "# **Grüße** friend 😊\nDo not speak this.\n```ts\nconsole.log('hidden');\n```\nEmail me@example.com — e.g., tomorrow.", + "Grüße friend\n\nEmail me at example.com, for example, tomorrow." + ), + ("hey - what's up", "hey, what's up"), + ("Wait—really – yes ‑ maybe", "Wait, really, yes, maybe"), + ("Read pages 1–5.", "Read pages 1 to 5."), + ("Ranges 1–5–10.", "Ranges 1 to 5 to 10."), + ("Already, — paused; — again.", "Already, paused; again."), + ("— Leading and trailing —", "Leading and trailing"), + ("Available 2026-07-01–2026-07-31.", "Available 2026-07-01 to 2026-07-31."), + ("Open Monday–Friday.", "Open Monday to Friday."), + ("state‑of‑the‑art and 123‒456 remain intact", "state‑of‑the‑art and 123‒456 remain intact"), + ( + "- Hyphen bullet\n+ Plus bullet\n* Asterisk bullet\n• Round bullet\n◦ Hollow bullet\n▪ Square bullet", + "Hyphen bullet.\nPlus bullet.\nAsterisk bullet.\nRound bullet.\nHollow bullet.\nSquare bullet." + ), + ("1. First\n2) Second\n(3) Third\n1000. Last", "First.\nSecond.\nThird.\nLast."), + ("Before\n---\nAfter\n\n* * *\n\nFinally\n_ _ _", "Before\n\nAfter\n\nFinally"), + ( + "It is -5 outside. Use state-of-the-art C++. 2 + 2 = 4.", + "It is -5 outside. Use state-of-the-art C++. 2 + 2 = 4." + ), + ("Visible.\nhidden forever", "Visible."), + ("Visible.\n~~~js\nhidden forever", "Visible."), + ("❤️ 👨‍👩‍👧‍👦 1️⃣", "1"), + ]) + func matchesResearchSanitization(input: String, expected: String) throws { + let sanitized = try SpeechTextPlanner.sanitize(input) + #expect(sanitized == expected) + } + + @Test func emptyHiddenAndEmojiContentNeverProducesSpeech() throws { + for text in [ + "", "❤️ 👨‍👩‍👧‍👦", "secret\n```\ncode\n```", + "\u{1f3f4}\u{e0067}\u{e0062}\u{e0065}\u{e006e}\u{e0067}\u{e007f}", + ] { + #expect(try SpeechTextPlanner.chunks(text).isEmpty) + } + } + @Test func chunksAtParagraphSentenceAndWordBoundaries() throws { + #expect(try SpeechTextPlanner.chunks("One\n\nTwo. Three", maximumWords: 4) == ["One Two. Three."]) + #expect( + try SpeechTextPlanner.chunks("Hello world. Goodbye friend.", maximumWords: 2) == [ + "Hello world.", "Goodbye friend.", + ]) + #expect( + try SpeechTextPlanner.chunks("one two three four five six", maximumWords: 2) == [ + "one two.", "three four.", "five six.", + ]) + #expect(try SpeechTextPlanner.chunks("- First\n- Second") == ["First. Second."]) + #expect(try SpeechTextPlanner.chunks("word ".repeating(60)).count == 1) + #expect( + try SpeechTextPlanner.chunks("word ".repeating(350)).map { $0.split(separator: " ").count } == [300, 50]) + } + @Test func unicodeSafetySplitKeepsComposedCharactersAndBoundsProviderCharacters() throws { + let chunks = try SpeechTextPlanner.chunks("é".repeating(1_001)) + #expect(chunks.map { $0.dropLast().unicodeScalars.count } == [1_000, 1]) + let grouped = try SpeechTextPlanner.chunks(("a".repeating(999) + " ").repeating(300)) + #expect(grouped.allSatisfy { $0.unicodeScalars.count <= 100_000 && $0.split(separator: " ").count <= 300 }) + let pathological = "a" + "\u{301}".repeating(1_002) + #expect(throws: SpeechIssue.capacity) { try SpeechTextPlanner.chunks(pathological) } + } + @Test func limitsRejectRatherThanTruncateText() throws { + #expect(throws: SpeechIssue.capacity) { + try SpeechTextPlanner.chunks("a".repeating(SpeechLimits.maximumSourceBytes + 1)) + } + #expect(throws: SpeechIssue.capacity) { try SpeechTextPlanner.chunks("word ".repeating(77_100)) } + #expect(try SpeechTextPlanner.chunks("hello", maximumWords: 0).isEmpty) + #expect(try SpeechTextPlanner.chunks("hello", maximumWords: 301).isEmpty) + } +} + +private extension String { func repeating(_ count: Int) -> String { String(repeating: self, count: count) } } diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Audio/TemporarySpeechStoreTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Audio/TemporarySpeechStoreTests.swift new file mode 100644 index 000000000..7b227b8f8 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Audio/TemporarySpeechStoreTests.swift @@ -0,0 +1,129 @@ +import Darwin +import Foundation +import Testing +@testable import MapleAppCore + +@Suite struct TemporarySpeechStoreTests { + private func root() -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent( + "speech-store-\(UUID().uuidString)", isDirectory: true) + } + private func body(_ data: Data) -> AsyncThrowingStream { + AsyncThrowingStream { + $0.yield(data); $0.finish() + } + } + @Test func protectedOpaqueFilesPersistUntilExplicitSessionCleanup() async throws { + let root = root(); defer { try? FileManager.default.removeItem(at: root) } + let store = TemporarySpeechStore(root: root), owner = SpeechOwner(account: accountScope()) + try await store.startupCleanup(); try await store.activate(owner) + let file = try await store.write(body(Data("wave".utf8)), owner: owner) + #expect(file.byteCount == 4) + #expect(await store.allocatedBytes == 4) + #expect(!file.url.lastPathComponent.contains(owner.account.principalID)) + var info = stat(); #expect(lstat(file.url.path, &info) == 0 && info.st_mode & 0o777 == 0o600) + #expect(try file.url.resourceValues(forKeys: [.isExcludedFromBackupKey]).isExcludedFromBackup == true) + #expect(try Data(contentsOf: file.url) == Data("wave".utf8)) + try await store.retire(owner) + #expect(!FileManager.default.fileExists(atPath: file.url.path)) + #expect(await store.allocatedBytes == 0) + } + @Test func perFileAndSessionCapsReclaimOnlyFailedWrites() async throws { + let root = root(); defer { try? FileManager.default.removeItem(at: root) } + let store = TemporarySpeechStore(root: root, maximumFileBytes: 4, maximumSessionBytes: 6) + let owner = SpeechOwner(account: accountScope()) + try await store.startupCleanup(); try await store.activate(owner) + await #expect(throws: SpeechIssue.capacity) { + try await store.write(body(Data(repeating: 1, count: 5)), owner: owner) + } + #expect(await store.allocatedBytes == 0) + let file = try await store.write(body(Data(repeating: 2, count: 4)), owner: owner) + await #expect(throws: SpeechIssue.capacity) { + try await store.write(body(Data(repeating: 3, count: 3)), owner: owner) + } + #expect(await store.allocatedBytes == 4) + #expect(try Data(contentsOf: file.url) == Data(repeating: 2, count: 4)) + #expect(try FileManager.default.contentsOfDirectory(atPath: root.path).count == 1) + } + @Test func retirementRejectsUncooperativeLateStreamAndSameAccountLease() async throws { + let root = root(); defer { try? FileManager.default.removeItem(at: root) } + let store = TemporarySpeechStore(root: root), owner = SpeechOwner(account: accountScope()) + try await store.startupCleanup(); try await store.activate(owner) + let stream = AsyncThrowingStream.makeStream() + let writing = Task { try await store.write(stream.stream, owner: owner) } + stream.continuation.yield(Data([1, 2, 3])) + for _ in 0..<10_000 { + if await store.allocatedBytes == 3 { break }; await Task.yield() + } + #expect(await store.allocatedBytes == 3) + try await store.retire(owner) + let nextAccount = AccountScope(apiOrigin: owner.account.apiOrigin, principalID: owner.account.principalID) + let next = SpeechOwner(account: nextAccount) + try await store.activate(next) + stream.continuation.yield(Data([4, 5])); stream.continuation.finish() + await #expect(throws: SpeechIssue.retired) { try await writing.value } + await #expect(throws: SpeechIssue.retired) { try await store.activate(owner) } + #expect(await store.allocatedBytes == 0) + #expect(try FileManager.default.contentsOfDirectory(atPath: root.path).isEmpty) + let current = try await store.write(body(Data([9])), owner: next) + try await store.retire(owner) + #expect(try Data(contentsOf: current.url) == Data([9])) + } + @Test func startupRemovesOnlyRecognizedOwnedFilesAndNeverFollowsLinks() async throws { + let root = root(); defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let keep = root.appendingPathComponent("keep.txt") + try Data("unrelated".utf8).write(to: keep) + let prefix = "speech-\(UUID().uuidString)-" + let orphan = root.appendingPathComponent(prefix + UUID().uuidString + ".wav") + let alias = root.appendingPathComponent(prefix + UUID().uuidString + ".wav") + try Data("old audio".utf8).write(to: orphan) + try FileManager.default.createSymbolicLink(at: alias, withDestinationURL: keep) + let store = TemporarySpeechStore(root: root) + try await store.startupCleanup() + #expect(try FileManager.default.contentsOfDirectory(atPath: root.path) == ["keep.txt"]) + #expect(try Data(contentsOf: keep) == Data("unrelated".utf8)) + } + @Test func rejectsSymlinkRootAndRequiresStartupCleanup() async throws { + let base = root(); defer { try? FileManager.default.removeItem(at: base) } + let real = base.appendingPathComponent("real"), alias = base.appendingPathComponent("alias") + try FileManager.default.createDirectory(at: real, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: alias, withDestinationURL: real) + let unsafe = TemporarySpeechStore(root: alias) + await #expect(throws: SpeechIssue.storage) { try await unsafe.startupCleanup() } + let uninitialized = TemporarySpeechStore(root: real) + await #expect(throws: SpeechIssue.storage) { + try await uninitialized.activate(SpeechOwner(account: accountScope())) + } + } + @Test func emptyAndOversizedFramesLeaveNoFile() async throws { + let root = root(); defer { try? FileManager.default.removeItem(at: root) } + let store = TemporarySpeechStore(root: root), owner = SpeechOwner(account: accountScope()) + try await store.startupCleanup(); try await store.activate(owner) + await #expect(throws: SpeechIssue.invalidAudio) { try await store.write(body(Data()), owner: owner) } + await #expect(throws: SpeechIssue.capacity) { + try await store.write( + body(Data(repeating: 0, count: SpeechLimits.maximumStreamBlockBytes + 1)), owner: owner) + } + #expect(await store.allocatedBytes == 0) + #expect(try FileManager.default.contentsOfDirectory(atPath: root.path).isEmpty) + } + @Test func failedRetirementRevokesImmediatelyAndBlocksNewOwnerUntilExactCleanup() async throws { + let root = root(); defer { try? FileManager.default.removeItem(at: root) } + let store = TemporarySpeechStore(root: root), owner = SpeechOwner(account: accountScope()) + try await store.startupCleanup(); try await store.activate(owner) + let file = try await store.write(body(Data([1, 2, 3])), owner: owner) + try FileManager.default.removeItem(at: file.url) + try FileManager.default.createDirectory(at: file.url, withIntermediateDirectories: false) + await #expect(throws: SpeechIssue.storage) { try await store.retire(owner) } + let next = SpeechOwner(account: accountScope()) + await #expect(throws: SpeechIssue.storage) { try await store.activate(next) } + await #expect(throws: SpeechIssue.retired) { try await store.write(body(Data([4])), owner: owner) } + try FileManager.default.removeItem(at: file.url) + try await store.retire(owner); try await store.activate(next) + #expect(await store.allocatedBytes == 0) + let current = try await store.write(body(Data([9])), owner: next) + try await store.retire(owner) + #expect(try Data(contentsOf: current.url) == Data([9])) + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Auth/AccountLinkInboxTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Auth/AccountLinkInboxTests.swift new file mode 100644 index 000000000..c9f2c92dd --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Auth/AccountLinkInboxTests.swift @@ -0,0 +1,128 @@ +import Foundation +import MapleSDK +import Synchronization +import Testing +@testable import MapleAppCore + +private actor LinkRestoreService: SessionServing { + var current: SessionIdentity? + let gate: ChatTestGate? + init(signedIn: Bool, gate: ChatTestGate?) { + self.gate = gate + if signedIn { + current = SessionIdentity(origin: "http://127.0.0.1:1234", principal: "fixture", generation: UUID()) + } + } + func identity() -> SessionIdentity? { current } + func user() async -> MapleUser { + let id = current?.principal ?? "absent" + if let gate { await gate.hold() } + return MapleUser( + id: id, email: "synthetic@example.test", emailVerified: false, loginMethod: "email", createdAt: "", + updatedAt: "") + } + func signIn(email: String, password: String) {} + func signInGuest(id: String, password: String) {} + func signUp(email: String, password: String, name: String?) {} + func signOut() { current = nil } +} +private actor LinkRestoreStorage: AccountDataLifecycle { + let gate: ChatTestGate? + init(gate: ChatTestGate?) { self.gate = gate } + func activate(_ scope: AccountScope) {} + func retire(_ scope: AccountScope) {} + func clearSignedOutData() async { if let gate { await gate.hold() } } +} + +@MainActor @Suite struct AccountLinkInboxTests { + private let verify = IncomingAccountLink.verify(code: "00000000-0000-4000-8000-000000000042") + + @Test(arguments: [false, true]) + func coldLinkSurvivesSuspendedRestoreAndDeliversExactlyOnce(signedIn: Bool) async { + let gate = ChatTestGate(), inbox = AccountLinkInbox() + let session = SessionController( + service: LinkRestoreService(signedIn: signedIn, gate: signedIn ? gate : nil), + storage: LinkRestoreStorage(gate: signedIn ? nil : gate)) + session.onCancelExternal = { inbox.retire() } + // Equivalent to receipt before the root has constructed its environment. + #expect(inbox.offer(verify, phase: .restoring, scope: nil) == nil) + let restore = Task { await session.restore() } + await gate.waitForArrival() + #expect(inbox.take(phase: session.phase, scope: session.account?.scope) == nil) + await gate.release(); await restore.value + #expect(session.phase == (signedIn ? .signedIn : .signedOut)) + #expect(inbox.take(phase: session.phase, scope: session.account?.scope) == verify) + #expect(inbox.take(phase: session.phase, scope: session.account?.scope) == nil) + } + + @Test func retirementDuringSuspendedRestoreRejectsItsWaitingLink() async { + let gate = ChatTestGate(), inbox = AccountLinkInbox() + let session = SessionController( + service: LinkRestoreService(signedIn: true, gate: gate), storage: LinkRestoreStorage(gate: nil)) + session.onCancelExternal = { inbox.retire() } + let restore = Task { await session.restore() } + await gate.waitForArrival() + #expect(inbox.offer(verify, phase: session.phase, scope: nil) == nil) + await session.signOut(); await gate.release(); await restore.value + #expect(session.phase == .signedOut) + #expect(inbox.take(phase: session.phase, scope: nil) == nil) + } + + @Test func newestValidLinkWinsButForeignOrOAuthInputCannotReplaceIt() throws { + let config = try AppConfiguration( + profile: .local, apiURL: "http://127.0.0.1:1234", billingURL: "http://127.0.0.1:1235", + flagsURL: "http://127.0.0.1:1236", authURL: "http://127.0.0.1:1237", + clientID: "00000000-0000-4000-8000-000000000001", callbackScheme: "cloud.opensecret.maple.native.dev") + let inbox = AccountLinkInbox() + #expect(inbox.offer(verify, phase: .restoring, scope: nil) == nil) + for value in [ + "http://127.0.0.1:1238/password-reset", "http://127.0.0.1:1237/password-reset?code=ABCD1234&code=EFGH5678", + "cloud.opensecret.maple.native.dev://auth/callback?grant=synthetic", + ] { + let parsed = IncomingAccountLink(url: try #require(URL(string: value)), configuration: config) + #expect(parsed == nil) + if let parsed { _ = inbox.offer(parsed, phase: .restoring, scope: nil) } + } + #expect(inbox.take(phase: .signedOut, scope: nil) == verify) + #expect(inbox.offer(verify, phase: .restoring, scope: nil) == nil) + let resetURL = try #require( + URL(string: "http://127.0.0.1:1237/password-reset?code=ABCD1234&secret=synthetic-external&email=other")) + let reset = try #require(IncomingAccountLink(url: resetURL, configuration: config)) + #expect(inbox.offer(reset, phase: .restoring, scope: nil) == nil) + #expect(inbox.take(phase: .signedOut, scope: nil) == .passwordReset(code: "ABCD1234")) + } + + @Test func knownScopeCannotFollowSameAccountReloginOrAnonymousRetirement() { + let inbox = AccountLinkInbox() + let original = AccountScope(apiOrigin: "http://127.0.0.1:1234", principalID: "same") + let replacement = AccountScope(apiOrigin: original.apiOrigin, principalID: original.principalID) + #expect(inbox.offer(verify, phase: .restoring, scope: original) == nil) + #expect(inbox.take(phase: .signedIn, scope: replacement) == nil) + #expect(inbox.offer(verify, phase: .restoring, scope: original) == nil) + #expect(inbox.take(phase: .signedOut, scope: nil) == nil) + #expect(inbox.offer(verify, phase: .restoring, scope: original) == nil) + #expect(inbox.take(phase: .signedIn, scope: original) == verify) + } + + @Test(arguments: [SessionPhase.authenticating, .cleaningUp]) + func receiptDuringAuthorityTransitionCannotWaitAndAdoptReplacement(_ phase: SessionPhase) { + let inbox = AccountLinkInbox() + let original = AccountScope(apiOrigin: "http://127.0.0.1:1234", principalID: "same") + let replacement = AccountScope(apiOrigin: original.apiOrigin, principalID: original.principalID) + // The root invokes this admission synchronously at receipt, before any + // subsequently scheduled restoration can publish a replacement account. + #expect(inbox.offer(verify, phase: phase, scope: original) == nil) + #expect(inbox.take(phase: .signedIn, scope: replacement) == nil) + } + + @Test func pendingRouteExpiresAndDoesNotCrossAnExternalSignIn() { + let elapsed = Mutex(0.0), inbox = AccountLinkInbox(monotonicNow: { elapsed.withLock { $0 } }) + #expect(inbox.offer(verify, phase: .restoring, scope: nil) == nil) + elapsed.withLock { $0 = 301 } + #expect(inbox.take(phase: .signedOut, scope: nil) == nil) + #expect(inbox.offer(verify, phase: .restoring, scope: nil) == nil) + #expect(inbox.take(phase: .unavailable, scope: nil) == nil) + #expect(inbox.take(phase: .authenticating, scope: nil) == nil) + #expect(inbox.take(phase: .signedOut, scope: nil) == nil) + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Auth/AuthCeremoniesTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Auth/AuthCeremoniesTests.swift new file mode 100644 index 000000000..2e64bc271 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Auth/AuthCeremoniesTests.swift @@ -0,0 +1,458 @@ +import Foundation +import MapleSDK +import Synchronization +import Testing +@testable import MapleAppCore + +private final class CeremonySecureStore: SecureStore, Sendable { + private let values = Mutex<[String: Data]>([:]) + func read(key: String) throws -> Data? { values.withLock { $0[key] } } + func write(_ data: Data, key: String) throws { values.withLock { $0[key] = data } } + func remove(key: String) throws { values.withLock { _ = $0.removeValue(forKey: key) } } +} +private final class CeremonyClock: Sendable { + private let date = Mutex(Date(timeIntervalSince1970: 1_800_000_000)) + func now() -> Date { date.withLock { $0 } } + func advance(_ interval: TimeInterval) { date.withLock { $0 = $0.addingTimeInterval(interval) } } +} +private func ceremonyToken(_ kind: String, principal: String) throws -> String { + func url(_ data: Data) -> String { + data.base64EncodedString().replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") + } + let body = try JSONSerialization.data(withJSONObject: [ + "sub": principal, "exp": 4_000_000_000, "tf": 2, + "aud": "urn:opensecret:internal:transport-v2:user:\(kind)-token", + ]) + return url(Data("{}".utf8)) + "." + url(body) + "." + url(Data("fixture".utf8)) +} +private actor CeremonyFixture: AuthCeremonyServing { + let authority: CredentialAuthority + let clock: CeremonyClock + var resetRequests: [(String, String)] = [] + var resetConfirmations: [(String, String, String, String)] = [] + var deletionRequests: [String] = [] + var deletionConfirmations = 0 + var appleNonces: [String] = [] + var snapshotReads = 0 + var browserPreparations: [UUID] = [] + var browserRedeems = 0 + var browserInstalls = 0 + var browserCancels: [UUID] = [] + var deletionError: ResearchAPIError? + var resetError: (any Error)? + var gate: ChatTestGate? + var providerGate: ChatTestGate? + var deletedGate: ChatTestGate? + var resetCompletedGate: ChatTestGate? + init(clock: CeremonyClock) throws { + self.clock = clock + authority = try CredentialAuthority(apiURL: "http://127.0.0.1:1234", store: CeremonySecureStore()) + } + func configure(gate: ChatTestGate? = nil, providerGate: ChatTestGate? = nil, deletionError: ResearchAPIError? = nil) + { + self.gate = gate; self.providerGate = providerGate; self.deletionError = deletionError + } + func install(_ principal: String = "account-a") async throws { + try await authority.clear() + let snapshot = try await authority.snapshot() + _ = try await authority.install( + accessToken: ceremonyToken("access", principal: principal), + refreshToken: ceremonyToken("refresh", principal: principal), expected: snapshot) + } + func setResetError(_ error: (any Error)?) { resetError = error } + func holdResetResult(_ gate: ChatTestGate) { resetCompletedGate = gate } + func holdDeletedResult(_ gate: ChatTestGate) { deletedGate = gate } + func snapshot() async throws -> CredentialSnapshot { + snapshotReads += 1 + return try await authority.snapshot() + } + func check(_ expected: CredentialSnapshot) async throws { + try Task.checkCancellation() + guard try await snapshot() == expected else { throw TransportError.authorityChanged } + } + func requestPasswordReset(email: String, hashedSecret: String, expected: CredentialSnapshot) async throws { + try await check(expected) + if let resetError { throw resetError } + resetRequests.append((email, hashedSecret)) + if let gate { await gate.hold() } + } + func confirmPasswordReset( + email: String, code: String, plaintextSecret: String, newPassword: String, + expected: CredentialSnapshot + ) async throws { + try await check(expected); resetConfirmations.append((email, code, plaintextSecret, newPassword)) + if let resetCompletedGate { await resetCompletedGate.hold() } + } + func verifyEmail(code: String, expected: CredentialSnapshot) async throws { + try await check(expected); if let gate { await gate.hold() } + } + func resendVerification(expected: CredentialSnapshot) async throws { try await check(expected) } + func changePassword(currentPassword: String, newPassword: String, expected: CredentialSnapshot) async throws { + try await check(expected) + } + func requestAccountDeletion(hashedSecret: String, expected: CredentialSnapshot) async throws { + try await check(expected); deletionRequests.append(hashedSecret) + } + func confirmAccountDeletion(code: String, plaintextSecret: String, expected: CredentialSnapshot) async throws { + try await check(expected); deletionConfirmations += 1 + if let deletionError { throw deletionError } + try await authority.clear(expected: expected) + if let deletedGate { await deletedGate.hold() } + } + func signInWithApple(_ credential: AppleSignInCredential, rawNonce: String, expected: CredentialSnapshot) + async throws + { + try await check(expected); appleNonces.append(rawNonce) + if let providerGate { await providerGate.hold() } + try await check(expected) + _ = try await authority.install( + accessToken: ceremonyToken("access", principal: "apple-account"), + refreshToken: ceremonyToken("refresh", principal: "apple-account"), expected: expected) + } + func prepareBrowser(id: UUID, expected: CredentialSnapshot) async throws -> BrowserHandoffReservation { + browserPreparations.append(id) + try await check(expected) + return BrowserHandoffReservation( + id: id, sessionID: String(repeating: "a", count: 32), + requestID: String(repeating: "b", count: 32), expiresAt: clock.now().addingTimeInterval(600)) + } + func redeemBrowser( + _ reservation: BrowserHandoffReservation, grant: NativeOAuthHandoffGrant, + expected: CredentialSnapshot + ) async throws -> BrowserAccountConfirmation { + try await check(expected); browserRedeems += 1 + if let providerGate { await providerGate.hold() } + try await check(expected) + return BrowserAccountConfirmation( + id: reservation.id, principalID: "browser-account", email: "browser@example.test") + } + func confirmBrowser(_ confirmation: BrowserAccountConfirmation, expected: CredentialSnapshot) async throws { + try await check(expected); browserInstalls += 1 + _ = try await authority.install( + accessToken: ceremonyToken("access", principal: confirmation.principalID), + refreshToken: ceremonyToken("refresh", principal: confirmation.principalID), expected: expected) + } + func cancelBrowser(id: UUID) { browserCancels.append(id) } +} + +@MainActor struct AuthCeremoniesTests { + private let secret = String(repeating: "d", count: 64) + private func make(signedIn: Bool = false, callbackScheme: String = "cloud.opensecret.maple.dev") async throws + -> (AuthCeremonies, CeremonyFixture, CeremonyClock) + { + let clock = CeremonyClock(), service = try CeremonyFixture(clock: clock) + if signedIn { try await service.install() } + let config = try AppConfiguration( + profile: .local, apiURL: "http://127.0.0.1:1234", billingURL: "http://127.0.0.1:1235", + flagsURL: "http://127.0.0.1:1236", authURL: "http://127.0.0.1:1237", + clientID: UUID().uuidString, callbackScheme: callbackScheme) + let scope = signedIn ? AccountScope(apiOrigin: config.apiURL.absoluteString, principalID: "account-a") : nil + let value = secret + return ( + AuthCeremonies( + service: service, configuration: config, scope: scope, + accountEmail: signedIn ? "account@example.test" : nil, now: { clock.now() }, makeSecret: { value }), + service, clock + ) + } + private func callback(scheme: String = "cloud.opensecret.maple.dev") -> URL { + URL(string: "\(scheme)://auth?handoff_grant=e30.e30.e30")! + } + + @Test func typedClockFailureDoesNotCreateChallengeAndCanBeRetried() async throws { + let (flow, service, clock) = try await make() + let error = try #require( + AttestationClockSkewError( + validatingDeviceTime: clock.now().addingTimeInterval(-600), enclaveTime: clock.now(), + certificateIndex: 0, notBefore: clock.now().addingTimeInterval(-60), + notAfter: clock.now().addingTimeInterval(3_600))) + await service.setResetError(NSError(domain: "Fixture", code: 1, userInfo: [NSUnderlyingErrorKey: error])) + await flow.requestPasswordReset(email: "first@example.test") + #expect(flow.state == .idle) + #expect(flow.issue == .clock(.deviceTime)) + #expect(await service.resetRequests.isEmpty) + await flow.confirmPasswordReset(code: "ABCD1234", newPassword: "password", confirmation: "password") + #expect(await service.resetConfirmations.isEmpty) + await service.setResetError(nil) + await flow.requestPasswordReset(email: "first@example.test") + #expect(flow.issue == nil) + #expect(flow.state == .resetCode(email: "first@example.test")) + #expect(await service.resetRequests.count == 1) + } + + @Test func resetBindsOriginalEmailAndSendsHashBeforePrivateSecret() async throws { + let (flow, service, _) = try await make() + await flow.requestPasswordReset(email: " first@example.test ") + #expect(flow.state == .resetCode(email: "first@example.test")) + #expect(await service.resetRequests.first?.0 == "first@example.test") + #expect(await service.resetRequests.first?.1 == hashSecret(secret)) + await flow.requestPasswordReset(email: "replacement@example.test") + await flow.confirmPasswordReset(code: "BAD", newPassword: "password", confirmation: "password") + #expect(await service.resetConfirmations.isEmpty) + await flow.confirmPasswordReset(code: "ABCD1234", newPassword: "password", confirmation: "password") + #expect(await service.resetConfirmations.first?.0 == "first@example.test") + #expect(await service.resetConfirmations.first?.2 == secret) + #expect(flow.state == .completed(.passwordReset)) + await flow.confirmPasswordReset(code: "ABCD1234", newPassword: "password", confirmation: "password") + #expect(await service.resetConfirmations.count == 1) + } + + @Test func accountResetPreservesSessionUntilSuccessAndRejectsReplacementGeneration() async throws { + let (flow, service, _) = try await make(signedIn: true) + let original = try await service.snapshot() + await flow.requestPasswordReset(email: "account@example.test") + #expect(flow.state == .resetCode(email: "account@example.test")) + #expect(try await service.snapshot() == original) + try await service.install() + await flow.confirmPasswordReset(code: "ABCD1234", newPassword: "password", confirmation: "password") + #expect(flow.issue == .changedAccount) + #expect(await service.resetConfirmations.isEmpty) + } + + @Test func accountResetRejectsAnotherDestinationWithoutRequestOrCleanup() async throws { + let (flow, service, _) = try await make(signedIn: true) + var cleanupCalls = 0 + flow.onPasswordResetResult = { _ in cleanupCalls += 1 } + await flow.requestPasswordReset(email: "different@example.test") + #expect(flow.issue == .invalidInput && flow.state == .idle) + #expect(await service.resetRequests.isEmpty) + #expect(cleanupCalls == 0) + } + + @Test func accountResetSucceedsWithCapturedEmailAndOriginalAuthority() async throws { + let (flow, service, _) = try await make(signedIn: true) + await flow.requestPasswordReset(email: "account@example.test") + await flow.requestPasswordReset(email: "different@example.test") + await flow.confirmPasswordReset(code: "ABCD1234", newPassword: "password", confirmation: "password") + #expect(flow.state == .completed(.passwordReset)) + #expect(await service.resetConfirmations.first?.0 == "account@example.test") + } + + @Test func confirmedResetAfterCancellationCleansCapturedScopeWithoutReplacingNewState() async throws { + let (flow, service, _) = try await make(signedIn: true), gate = ChatTestGate() + var cleaned: [AccountScope] = [] + flow.onPasswordResetResult = { cleaned.append($0) } + await service.holdResetResult(gate) + await flow.requestPasswordReset(email: "account@example.test") + let confirming = Task { + await flow.confirmPasswordReset(code: "ABCD1234", newPassword: "password", confirmation: "password") + } + await gate.waitForArrival(); await flow.cancel() + await flow.requestAccountDeletion(confirmation: "DELETE") + #expect(flow.state == .deletionCode) + await gate.release(); await confirming.value + #expect(cleaned == [try #require(flow.scope)]) + #expect(flow.state == .deletionCode) + } + + @Test(arguments: [901.0, -1.0]) func expiredOrBackwardsClockDropsResetSecret(age: Double) async throws { + let (flow, service, clock) = try await make() + await flow.requestPasswordReset(email: "first@example.test") + clock.advance(age) + await flow.confirmPasswordReset(code: "ABCD1234", newPassword: "password", confirmation: "password") + #expect(flow.issue == .expired) + #expect(flow.state == .idle) + #expect(await service.resetConfirmations.isEmpty) + } + + @Test func canceledResetRequestCannotResurrectConfirmation() async throws { + let (flow, service, _) = try await make(), gate = ChatTestGate() + await service.configure(gate: gate) + let request = Task { await flow.requestPasswordReset(email: "first@example.test") } + await gate.waitForArrival() + await flow.cancel() + await gate.release(); await request.value + #expect(flow.state == .idle) + await flow.confirmPasswordReset(code: "ABCD1234", newPassword: "password", confirmation: "password") + #expect(await service.resetConfirmations.isEmpty) + } + + @Test func deletionRequiresFenceAndBillingFailureRemainsSignedInForRetry() async throws { + let (flow, service, _) = try await make(signedIn: true) + await flow.requestAccountDeletion(confirmation: "delete") + #expect(await service.deletionRequests.isEmpty) + await flow.requestAccountDeletion(confirmation: "DELETE") + #expect(await service.deletionRequests == [hashSecret(secret)]) + let code = UUID().uuidString + await flow.confirmAccountDeletion(code: code) + #expect(flow.issue == .cleanupRequired) + #expect(await service.deletionConfirmations == 0) + var fenced = 0, results: [Bool] = [] + flow.beforeAccountDeletion = { _ in fenced += 1 } + flow.onAccountDeletionResult = { _, deleted in results.append(deleted) } + await service.configure(deletionError: .http(status: 503, reason: .billingCleanupUnavailable)) + await flow.confirmAccountDeletion(code: code) + #expect(flow.issue == .billingCleanupUnavailable) + #expect(flow.state == .deletionCode) + #expect(try await service.snapshot().principalId == "account-a") + await service.configure() + await flow.confirmAccountDeletion(code: code) + #expect(flow.state == .completed(.accountDeleted)) + #expect(try await service.snapshot().principalId == nil) + #expect(fenced == 2 && results == [false, true]) + } + + @Test func sameAccountReloginInvalidatesDeletionChallenge() async throws { + let (flow, service, _) = try await make(signedIn: true) + await flow.requestAccountDeletion(confirmation: "DELETE") + try await service.install() + flow.beforeAccountDeletion = { _ in Issue.record("Stale deletion must not acquire a fence") } + await flow.confirmAccountDeletion(code: UUID().uuidString) + #expect(flow.issue == .changedAccount) + #expect(await service.deletionConfirmations == 0) + } + + @Test func confirmedDeletionAfterCancellationCleansOldScopeWithoutReplacingNewState() async throws { + let (flow, service, _) = try await make(signedIn: true), gate = ChatTestGate() + var deletedResults: [Bool] = [] + flow.beforeAccountDeletion = { _ in } + flow.onAccountDeletionResult = { _, deleted in deletedResults.append(deleted) } + await flow.requestAccountDeletion(confirmation: "DELETE") + await service.holdDeletedResult(gate) + let deletion = Task { await flow.confirmAccountDeletion(code: UUID().uuidString) } + await gate.waitForArrival() + await flow.cancel() + try await service.install() + await flow.requestAccountDeletion(confirmation: "DELETE") + #expect(flow.state == .deletionCode) + await gate.release(); await deletion.value + #expect(flow.state == .deletionCode) + #expect(deletedResults == [true]) + #expect(try await service.snapshot().principalId == "account-a") + } + + @Test func appleStateAndNonceAreOwnedAndCallbackIsSingleUse() async throws { + let (flow, service, _) = try await make() + let request = try #require(await flow.beginAppleSignIn()) + #expect(request.nonce == hashSecret(secret) && request.nonce != secret) + await flow.completeAppleSignIn( + id: UUID(), credential: .init(identityToken: "fixture", userIdentifier: "id", state: request.state)) + #expect(await service.appleNonces.isEmpty) + await flow.completeAppleSignIn( + id: request.id, credential: .init(identityToken: "fixture", userIdentifier: "id", state: "wrong")) + #expect(flow.issue == .invalidCallback) + #expect(await service.appleNonces.isEmpty) + await flow.completeAppleSignIn( + id: request.id, credential: .init(identityToken: "fixture", userIdentifier: "id", state: request.state)) + #expect(await service.appleNonces.isEmpty) + let next = try #require(await flow.beginAppleSignIn()) + await flow.completeAppleSignIn( + id: next.id, credential: .init(identityToken: "fixture", userIdentifier: "id", state: next.state)) + #expect(await service.appleNonces == [secret]) + #expect(flow.state == .completed(.signedIn)) + } + + @Test func canceledAppleNetworkCompletionCannotInstall() async throws { + let (flow, service, _) = try await make(), gate = ChatTestGate() + await service.configure(providerGate: gate) + let request = try #require(await flow.beginAppleSignIn()) + let completion = Task { + await flow.completeAppleSignIn( + id: request.id, credential: .init(identityToken: "fixture", userIdentifier: "id", state: request.state)) + } + await gate.waitForArrival() + flow.invalidate() + await gate.release(); await completion.value + #expect(try await service.snapshot().principalId == nil) + #expect(flow.state == .idle) + } + + @Test(arguments: [OAuthProvider.google, .github]) + func unsupportedBrowserTargetDoesNotReserveOrConsumeAuthority(_ provider: OAuthProvider) async throws { + // Synthetic unsupported scheme; not a selected application bundle identity. + let (flow, service, _) = try await make(callbackScheme: "cloud.opensecret.maple.fixture-unregistered") + let original = try await service.snapshot() + let reads = await service.snapshotReads + for _ in 0..<2 { + #expect(await flow.beginBrowserSignIn(provider: provider) == nil) + #expect(flow.state == .idle) + #expect(flow.issue == .browserSignInUnavailable) + } + await flow.redeemBrowserCallback(id: UUID(), url: callback()) + await flow.confirmBrowserAccount(id: UUID()) + await flow.cancel() + #expect(await service.snapshotReads == reads) + #expect(await service.browserPreparations.isEmpty) + #expect(await service.browserRedeems == 0) + #expect(await service.browserInstalls == 0) + #expect(await service.browserCancels.isEmpty) + #expect(try await service.snapshot() == original) + // The unavailable provider target does not retire unrelated account flows. + await flow.requestPasswordReset(email: "first@example.test") + #expect(flow.state == .resetCode(email: "first@example.test")) + #expect(flow.issue == nil) + #expect(await service.resetRequests.count == 1) + } + + @Test(arguments: ["cloud.opensecret.maple", "cloud.opensecret.maple.dev"]) + func supportedBrowserTargetsKeepTheirExactBrokerMapping(_ scheme: String) async throws { + let (flow, service, _) = try await make(callbackScheme: scheme) + let request = try #require(await flow.beginBrowserSignIn(provider: .github)) + let parts = try #require(URLComponents(url: request.url, resolvingAgainstBaseURL: false)) + let variant = parts.queryItems?.first(where: { $0.name == "native_app_variant" })?.value + #expect(request.callbackScheme == scheme) + #expect(parts.path == "/desktop-auth") + #expect(parts.queryItems?.first(where: { $0.name == "provider" })?.value == "github") + #expect(parts.queryItems?.first(where: { $0.name == "transport" })?.value == "v2") + #expect(variant == (scheme == "cloud.opensecret.maple.dev" ? "dev" : nil)) + #expect(await service.browserPreparations == [request.id]) + await flow.cancel() + #expect(await service.browserCancels == [request.id]) + } + + @Test func browserLocalV2RouteRequiresExplicitVerifiedAccountConfirmation() async throws { + let (flow, service, _) = try await make() + let request = try #require(await flow.beginBrowserSignIn(provider: .google)) + let parts = try #require(URLComponents(url: request.url, resolvingAgainstBaseURL: false)) + #expect(parts.host == "127.0.0.1" && parts.port == 1237 && parts.path == "/desktop-auth") + #expect(parts.queryItems?.first(where: { $0.name == "native_app_variant" })?.value == "dev") + #expect(parts.queryItems?.first(where: { $0.name == "transport" })?.value == "v2") + await flow.redeemBrowserCallback(id: request.id, url: callback()) + #expect( + flow.state + == .confirmBrowser(.init(id: request.id, principalID: "browser-account", email: "browser@example.test")) + ) + #expect(await service.browserInstalls == 0) + #expect(try await service.snapshot().principalId == nil) + await flow.redeemBrowserCallback(id: request.id, url: callback()) + #expect(await service.browserRedeems == 1) + await flow.confirmBrowserAccount(id: request.id) + await flow.confirmBrowserAccount(id: request.id) + #expect(await service.browserInstalls == 1) + #expect(flow.state == .completed(.signedIn)) + } + + @Test func wrongSchemeConsumesBrowserAttemptWithoutRedemption() async throws { + let (flow, service, _) = try await make() + let request = try #require(await flow.beginBrowserSignIn(provider: .github)) + await flow.redeemBrowserCallback(id: request.id, url: callback(scheme: "cloud.opensecret.maple")) + #expect(flow.issue == .invalidCallback) + #expect(await service.browserRedeems == 0) + #expect(await service.browserCancels == [request.id]) + await flow.redeemBrowserCallback(id: request.id, url: callback()) + #expect(await service.browserRedeems == 0) + } + + @Test func confirmationExpiryNeverInstallsRedeemedBrowserCredentials() async throws { + let (flow, service, clock) = try await make() + let request = try #require(await flow.beginBrowserSignIn(provider: .google)) + await flow.redeemBrowserCallback(id: request.id, url: callback()) + clock.advance(601) + await flow.confirmBrowserAccount(id: request.id) + #expect(flow.issue == .expired) + #expect(await service.browserInstalls == 0) + #expect(await service.browserCancels == [request.id]) + } + + @Test func delayedVerificationCannotPublishForReplacementAccount() async throws { + let (flow, service, _) = try await make(signedIn: true), gate = ChatTestGate() + await service.configure(gate: gate) + let verification = Task { await flow.verifyEmail(code: UUID().uuidString) } + await gate.waitForArrival() + try await service.install() + await gate.release(); await verification.value + #expect(flow.issue == .changedAccount) + #expect(flow.state == .idle) + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Auth/ClockGuidanceTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Auth/ClockGuidanceTests.swift new file mode 100644 index 000000000..01d3be724 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Auth/ClockGuidanceTests.swift @@ -0,0 +1,106 @@ +import Foundation +import MapleSDK +import Testing +@testable import MapleAppCore + +private func clockDiagnostic(seconds: TimeInterval = 3 * 86_400) throws -> AttestationClockSkewError { + let signedAt = Date(timeIntervalSince1970: 1_800_000_000) + return try #require( + AttestationClockSkewError( + validatingDeviceTime: signedAt.addingTimeInterval(seconds), enclaveTime: signedAt, + certificateIndex: 0, notBefore: signedAt.addingTimeInterval(-600), + notAfter: signedAt.addingTimeInterval(3_600))) +} + +private actor ClockSessionService: SessionServing { + private var current: SessionIdentity? + private var failure: (any Error)? + private(set) var revocations = 0 + init(signedIn: Bool, failure: any Error) { + self.failure = failure + if signedIn { current = Self.identityValue() } + } + private static func identityValue() -> SessionIdentity { + SessionIdentity(origin: "http://127.0.0.1:1234", principal: "clock-fixture", generation: UUID()) + } + func identity() -> SessionIdentity? { current } + func clearFailure() { failure = nil } + func user() throws -> MapleUser { + if let failure { throw failure } + return MapleUser( + id: "clock-fixture", email: "clock@example.test", emailVerified: true, + loginMethod: "email", createdAt: "", updatedAt: "") + } + func signIn(email: String, password: String) throws { + if let failure { throw failure } + current = Self.identityValue() + } + func signInGuest(id: String, password: String) throws { try signIn(email: id, password: password) } + func signUp(email: String, password: String, name: String?) throws { try signIn(email: email, password: password) } + func signOut() { revocations += 1; current = nil } +} +private actor ClockStorage: AccountDataLifecycle { + private(set) var clears = 0 + private(set) var retirements = 0 + private(set) var activations = 0 + func activate(_ scope: AccountScope) { activations += 1 } + func retire(_ scope: AccountScope) { retirements += 1 } + func clearSignedOutData() { clears += 1 } +} + +@MainActor struct ClockGuidanceTests { + @Test func classificationUsesTypedBoundedChainAndDistinguishesNearCertificateFailure() throws { + #expect(AttestationClockIssue.classify(try clockDiagnostic()) == .deviceTime) + #expect(AttestationClockIssue.classify(try clockDiagnostic(seconds: 30)) == .certificateTiming) + var wrapped: any Error = try clockDiagnostic() + for _ in 0..<7 { wrapped = NSError(domain: "Fixture", code: 1, userInfo: [NSUnderlyingErrorKey: wrapped]) } + #expect(AttestationClockIssue.classify(wrapped) == .deviceTime) + wrapped = NSError(domain: "Fixture", code: 1, userInfo: [NSUnderlyingErrorKey: wrapped]) + #expect(AttestationClockIssue.classify(wrapped) == nil) + let spoofed = NSError( + domain: "AttestationClockSkewError", code: 1, + userInfo: [NSLocalizedDescriptionKey: "Check the device date, time and time zone settings"]) + #expect(AttestationClockIssue.classify(spoofed) == nil) + #expect(AttestationClockIssue.classify(TrustError.certificateChain) == nil) + #expect(AttestationClockIssue.classify(ResearchAPIError.http(status: 401, reason: .invalidLogin)) == nil) + } + + @Test func failedLoginKeepsFormAvailableAndExplicitRetryInstallsAccount() async throws { + let wrapped = NSError(domain: "Fixture", code: 1, userInfo: [NSUnderlyingErrorKey: try clockDiagnostic()]) + let service = ClockSessionService(signedIn: false, failure: wrapped), storage = ClockStorage() + let controller = SessionController(service: service, storage: storage) + await controller.restore() + await controller.signIn(email: "clock@example.test", password: "synthetic") + #expect(controller.phase == .signedOut) + #expect(controller.issue == .clock(.deviceTime)) + #expect(controller.account == nil) + #expect(await service.identity() == nil) + #expect(await service.revocations == 0) + #expect(await storage.activations == 0) + await service.clearFailure() + await controller.signIn(email: "clock@example.test", password: "synthetic") + #expect(controller.phase == .signedIn) + #expect(controller.issue == nil) + #expect(await storage.activations == 1) + } + + @Test func clockFailureDuringRestorePreservesCredentialsAndPrivateWorkForRetry() async throws { + let service = ClockSessionService(signedIn: true, failure: try clockDiagnostic(seconds: 30)) + let storage = ClockStorage() + let ownedController = SessionController(service: service, storage: storage) + let identity = await service.identity() + await ownedController.restore() + #expect(ownedController.phase == .unavailable) + #expect(ownedController.issue == .clock(.certificateTiming)) + #expect(await service.identity() == identity) + #expect(await service.revocations == 0) + #expect(await storage.clears == 0) + #expect(await storage.retirements == 0) + #expect(await storage.activations == 0) + await service.clearFailure() + await ownedController.restore() + #expect(ownedController.phase == .signedIn) + #expect(await service.identity() == identity) + #expect(await storage.activations == 1) + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Auth/IncomingAccountLinkTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Auth/IncomingAccountLinkTests.swift new file mode 100644 index 000000000..c8521ab0c --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Auth/IncomingAccountLinkTests.swift @@ -0,0 +1,40 @@ +import Foundation +import Testing +@testable import MapleAppCore + +struct IncomingAccountLinkTests { + private func configuration() throws -> AppConfiguration { + try AppConfiguration( + profile: .local, apiURL: "http://127.0.0.1:10001", billingURL: "http://127.0.0.1:10002", + flagsURL: "http://127.0.0.1:10003", authURL: "http://127.0.0.1:10004", + clientID: "00000000-0000-4000-8000-000000000001", callbackScheme: "cloud.opensecret.maple.native.dev") + } + + @Test func exactVerificationAndResetRoutesCarryNoExternalSecret() throws { + let configuration = try configuration() + let code = "00000000-0000-4000-8000-000000000002" + #expect( + IncomingAccountLink( + url: URL(string: "http://127.0.0.1:10004/verify/\(code)")!, configuration: configuration) + == .verify(code: code)) + #expect( + IncomingAccountLink( + url: URL(string: "http://127.0.0.1:10004/password-reset/confirm?email=synthetic&secret=untrusted")!, + configuration: configuration) == .passwordReset(code: nil)) + #expect( + IncomingAccountLink( + url: URL(string: "http://127.0.0.1:10004/password-reset?code=ABCD1234")!, configuration: configuration) + == .passwordReset(code: "ABCD1234")) + } + + @Test(arguments: [ + "http://127.0.0.1:10005/password-reset", "https://127.0.0.1:10004/password-reset", + "http://user@127.0.0.1:10004/password-reset", "http://127.0.0.1:10004/password-reset#x", + "http://127.0.0.1:10004/password-reset?code=ABCD1234&code=ABCD1234", + "http://127.0.0.1:10004/password-reset?next=/payment", "http://127.0.0.1:10004/password-reset?code=abcd1234", + "http://127.0.0.1:10004/payment", "http://127.0.0.1:10004/verify/a/b", + "cloud.opensecret.maple.native.dev://password-reset", "http://127.0.0.1:10004/verify/%2F", + ]) func rejectsAmbiguousForeignAndDeferredRoutes(_ value: String) throws { + #expect(IncomingAccountLink(url: URL(string: value)!, configuration: try configuration()) == nil) + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Auth/SessionAuthorityTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Auth/SessionAuthorityTests.swift new file mode 100644 index 000000000..29e19f106 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Auth/SessionAuthorityTests.swift @@ -0,0 +1,277 @@ +import Foundation +import MapleSDK +import Synchronization +import Testing +@testable import MapleAppCore + +private final class AuthoritySecureStore: SecureStore, Sendable { + struct State { var data: [String: Data] = [:]; var fail = false } + let state = Mutex(State()) + func read(key: String) -> Data? { state.withLock { $0.data[key] } } + func write(_ data: Data, key: String) throws { + try state.withLock { state in + if state.fail { throw TransportError.persistence } + state.data[key] = data + } + } + func remove(key: String) { _ = state.withLock { $0.data.removeValue(forKey: key) } } + func fail(_ value: Bool) { state.withLock { $0.fail = value } } +} + +private actor ObservedSessionService: SessionServing { + let authority: CredentialAuthority + var signOuts = 0 + var userGate: ChatTestGate? + var userFailure: (any Error)? + var identityCalls = 0 + var heldIdentityCall: Int? + var identityGate: ChatTestGate? + func holdIdentity(_ call: Int, gate: ChatTestGate) { heldIdentityCall = call; identityGate = gate } + init(_ authority: CredentialAuthority) { self.authority = authority } + func identity() async throws -> SessionIdentity? { + let value = try await authority.snapshot() + let captured = value.principalId.map { + SessionIdentity(origin: value.apiOrigin, principal: $0, generation: value.generation) + } + identityCalls += 1 + if identityCalls == heldIdentityCall, let identityGate { await identityGate.hold() } + return captured + } + func authorityChanges() async -> AsyncStream? { await authority.changes() } + func authorityState() async -> CredentialAuthorityChange? { await authority.currentState() } + func completePendingCleanup() async throws { try await authority.completePendingCleanup() } + func user() async throws -> MapleUser { + let principal = try await authority.snapshot().principalId ?? "absent" + if let userGate { await userGate.hold() } + if let userFailure { throw userFailure } + return MapleUser( + id: principal, email: "fixture@example.test", emailVerified: true, + loginMethod: "email", createdAt: "", updatedAt: "") + } + func setUserGate(_ gate: ChatTestGate) { userGate = gate } + func setUserFailure(_ error: any Error) { userFailure = error } + func install(_ principal: String) async throws { + func token(_ kind: String) throws -> String { + let data = try JSONSerialization.data(withJSONObject: [ + "sub": principal, "exp": 4_000_000_000, "tf": 2, + "aud": "urn:opensecret:internal:transport-v2:user:\(kind)-token", + ]) + let payload = data.base64EncodedString().replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") + return "e30.\(payload).c2ln" + } + _ = try await authority.install( + accessToken: token("access"), refreshToken: token("refresh"), expected: authority.snapshot()) + } + func signIn(email: String, password: String) async throws { try await install(email) } + func signInGuest(id: String, password: String) async throws { try await install(id) } + func signUp(email: String, password: String, name: String?) async throws { try await install(email) } + func signOut(ifOwnedBy identity: SessionIdentity?) async throws { + guard let identity, try await self.identity() == identity else { return } + try await signOut() + } + func signOut() async throws { + signOuts += 1 + try await authority.completePendingCleanup() + try await authority.clear() + } +} + +private actor ObservedLifecycle: AccountDataLifecycle { + var activations: [AccountScope] = [] + var retirements: [AccountScope] = [] + var clears = 0 + var failure = false + var clearGate: ChatTestGate? + func setClearGate(_ gate: ChatTestGate) { clearGate = gate } + func setFailure(_ value: Bool) { failure = value } + func activate(_ scope: AccountScope) { activations.append(scope) } + func retire(_ scope: AccountScope) throws { + retirements.append(scope) + if failure { throw ChatStorageError.cleanupRequired } + } + func clearSignedOutData() async throws { + clears += 1 + if let clearGate { await clearGate.hold() } + if failure { throw ChatStorageError.cleanupRequired } + } +} + +@MainActor @Suite struct SessionAuthorityTests { + private func fixture() async throws -> ( + SessionController, ObservedSessionService, ObservedLifecycle, AuthoritySecureStore + ) { + let secure = AuthoritySecureStore() + let authority = try CredentialAuthority(apiURL: "http://127.0.0.1:1234", store: secure) + let service = ObservedSessionService(authority), storage = ObservedLifecycle() + try await service.install("original") + let session = SessionController(service: service, storage: storage) + await session.restore() + return (session, service, storage, secure) + } + private func settle(_ check: @MainActor () -> Bool) async { + for _ in 0..<20_000 { + if check() { return } + await Task.yield() + } + #expect(check()) + } + + @Test func revokedAuthorityImmediatelyRetiresPrivateOwnerWithoutSigningOutAgain() async throws { + let (session, service, storage, _) = try await fixture() + let scope = try #require(session.account?.scope) + let gate = ChatTestGate() + session.onRetire = { retired in + #expect(retired == scope); await gate.hold() + } + try await service.authority.clear() + await gate.waitForArrival() + #expect(session.account == nil && session.phase == .cleaningUp) + await gate.release() + await settle { session.phase == .signedOut } + #expect(await storage.retirements == [scope]) + #expect(await service.signOuts == 0) + } + + @Test func replacementDuringRetirementResolvesNewestAndOldEventsCannotRetireIt() async throws { + let (session, service, storage, _) = try await fixture() + let original = try #require(session.account?.scope) + let gate = ChatTestGate() + session.onRetire = { _ in await gate.hold() } + try await service.install("replacement-one") + let stale = await service.authority.currentState() + await gate.waitForArrival() + try await service.install("replacement-two") + #expect(session.account == nil) + await gate.release() + await settle { session.account?.scope.principalID == "replacement-two" } + let current = try #require(session.account?.scope) + await session.authorityChanged(stale) + #expect(session.account?.scope == current) + #expect(await service.signOuts == 0) + #expect(await storage.retirements == [original]) + #expect(await storage.activations.count == 2) + } + + @Test func failedKeychainClearRevokesAppAndBlocksActivationUntilExplicitRepair() async throws { + let (session, service, storage, secure) = try await fixture() + secure.fail(true) + await #expect(throws: TransportError.persistence) { try await service.authority.clear() } + await settle { session.phase == .unavailable && session.issue == .cleanup } + #expect(session.account == nil) + await session.restore() + #expect(session.phase == .unavailable && session.issue == .cleanup) + await session.signIn(email: "blocked", password: "synthetic") + #expect(await storage.activations.count == 1) + secure.fail(false) + await session.restore() + #expect(session.phase == .signedOut) + #expect(await service.signOuts == 0) + await session.signIn(email: "new", password: "synthetic") + #expect(session.account?.scope.principalID == "new") + } + + @Test func privateCleanupFailurePreservesReplacementCredentialsUntilRestoreRetry() async throws { + let (session, service, storage, _) = try await fixture() + await storage.setFailure(true) + try await service.install("replacement") + await settle { session.phase == .unavailable && session.issue == .cleanup } + #expect(try await service.identity()?.principal == "replacement") + #expect(session.account == nil) + #expect(await storage.activations.count == 1) + await storage.setFailure(false) + await session.restore() + #expect(session.account?.scope.principalID == "replacement") + #expect(await service.signOuts == 0) + } + + @Test func ordinaryUnauthorizedProfileFailureDoesNotInventAuthorityRevocation() async throws { + let (session, service, storage, _) = try await fixture() + let scope = session.account?.scope + await service.setUserFailure(ResearchAPIError.http(status: 401, reason: .invalidLogin)) + await session.refreshUser() + #expect(session.account?.scope == scope && session.phase == .signedIn) + #expect(await storage.retirements.isEmpty) + #expect(await service.signOuts == 0) + } + + @Test func explicitLogoutCleanupRetryThroughRestoreStillRevokesAuthority() async throws { + let (session, service, storage, _) = try await fixture() + await storage.setFailure(true) + await session.signOut() + #expect(session.phase == .unavailable && session.issue == .cleanup) + await storage.setFailure(false) + await session.restore() + #expect(session.phase == .signedOut && session.account == nil) + #expect(await service.signOuts == 1) + } + + @Test func installDuringSignedOutCleanupIsResolvedEvenWhenItsEventWasAlreadyHandled() async throws { + let secure = AuthoritySecureStore() + let authority = try CredentialAuthority(apiURL: "http://127.0.0.1:1234", store: secure) + let service = ObservedSessionService(authority), storage = ObservedLifecycle(), gate = ChatTestGate() + await storage.setClearGate(gate) + let session = SessionController(service: service, storage: storage) + let restore = Task { await session.restore() } + await gate.waitForArrival() + try await service.install("installed-during-cleanup") + await session.authorityChanged(authority.currentState()) + await gate.release(); await restore.value + #expect(session.account?.scope.principalID == "installed-during-cleanup") + #expect(await storage.activations.count == 1) + } + + @Test func installAfterFinalNilReadButBeforePublicationCannotBeLost() async throws { + let authority = try CredentialAuthority(apiURL: "http://127.0.0.1:1234", store: AuthoritySecureStore()) + let service = ObservedSessionService(authority), storage = ObservedLifecycle(), gate = ChatTestGate() + await service.holdIdentity(2, gate: gate) + let session = SessionController(service: service, storage: storage) + let restore = Task { await session.restore() } + await gate.waitForArrival() + try await service.install("replacement") + await session.authorityChanged(authority.currentState()) + await gate.release(); await restore.value + #expect(session.account?.scope.principalID == "replacement") + #expect(await storage.activations.count == 1) + } + + @Test func explicitLogoutNeverRevokesReplacementInstalledDuringRetirement() async throws { + let (session, service, storage, _) = try await fixture() + let original = try #require(session.account?.scope), gate = ChatTestGate() + session.onRetire = { _ in await gate.hold() } + let logout = Task { await session.signOut() } + await gate.waitForArrival() + try await service.install("replacement") + await gate.release(); await logout.value + #expect(session.account?.scope.principalID == "replacement") + #expect(await service.signOuts == 0) + #expect(await storage.retirements == [original]) + } + + @Test func completedLogoutDoesNotRevokeNewAuthorityWhenOnlyPrivateCleanupIsRetried() async throws { + let (session, service, storage, _) = try await fixture() + await storage.setFailure(true) + await session.signOut() + #expect(session.issue == .cleanup && session.account == nil) + try await service.install("replacement") + await storage.setFailure(false) + await session.restore() + #expect(session.account?.scope.principalID == "replacement") + #expect(await service.signOuts == 1) + } + + @Test func revocationDuringRestoreRejectsLateProfilePublication() async throws { + let secure = AuthoritySecureStore() + let authority = try CredentialAuthority(apiURL: "http://127.0.0.1:1234", store: secure) + let service = ObservedSessionService(authority), storage = ObservedLifecycle(), gate = ChatTestGate() + try await service.install("original"); await service.setUserGate(gate) + let session = SessionController(service: service, storage: storage) + let restore = Task { await session.restore() } + await gate.waitForArrival() + try await authority.clear() + await settle { session.phase == .signedOut } + await gate.release(); await restore.value + #expect(session.phase == .signedOut && session.account == nil) + #expect(await storage.activations.isEmpty) + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatCompletionFeedbackTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatCompletionFeedbackTests.swift new file mode 100644 index 000000000..f6721c035 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatCompletionFeedbackTests.swift @@ -0,0 +1,248 @@ +import Foundation +import MapleSDK +import Testing +@testable import MapleAppCore + +@MainActor @Suite struct ChatCompletionFeedbackTests { + enum HiddenReason: CaseIterable, Sendable, Equatable { case anotherScreen, background } + + private func hide(_ runtime: ChatRuntime, _ reason: HiddenReason) { + runtime.isVisible = reason != .anotherScreen + runtime.isForeground = reason != .background + } + private func waitUntil(_ condition: @MainActor () -> Bool) async { + for _ in 0..<20_000 { + if condition() { return } + await Task.yield() + } + #expect(condition()) + } + + @Test func sendHasNoFeedbackAndDuplicateSuccessfulTerminalProducesOnlyOneRequest() async throws { + let (runtime, service, _) = ChatRuntimeTests().fixture(), gate = ChatTestGate() + var feedback = 0, completions = 0 + runtime.isVisible = true; runtime.isForeground = true + runtime.onSuccessfulVisibleCompletion = { _ in feedback += 1 } + runtime.onCompletion = { _ in completions += 1 } + let completed = ResponseStreamEvent.completed(try chatLifecycle(chatResponse("response", .completed))) + await service.setResponseGate(gate); await service.setScript([completed, completed]) + runtime.draftText = "Synthetic feedback turn"; #expect(runtime.send()) + await gate.waitForArrival() + #expect(feedback == 0 && completions == 0) + await gate.release(); await runtime.waitForCurrentWork() + #expect(runtime.lastOutcome == .completed && feedback == 1 && completions == 1) + runtime.isVisible = false; runtime.isVisible = true + await runtime.reconcile() + #expect(feedback == 1 && completions == 1) + runtime.invalidate() + } + + @Test(arguments: [ResponseStatus.failed, .cancelled, .incomplete]) + func unsuccessfulTerminalNeverRequestsSuccessFeedback(_ status: ResponseStatus) async throws { + let (runtime, service, _) = ChatRuntimeTests().fixture() + var feedback = 0, completions = 0 + runtime.isVisible = true; runtime.isForeground = true + runtime.onSuccessfulVisibleCompletion = { _ in feedback += 1 } + runtime.onSuccessfulCompletion = { _ in completions += 1 } + let event: ResponseStreamEvent + if status == .cancelled { + event = .cancelled( + try JSONDecoder().decode(ResponseCancelledEvent.self, from: Data(#"{"id":"response"}"#.utf8))) + } else { + event = .failed(try chatLifecycle(chatResponse("response", status))) + } + await service.setScript([event]) + runtime.draftText = "Synthetic unsuccessful turn"; #expect(runtime.send()) + await runtime.waitForCurrentWork() + #expect(runtime.lastOutcome == ChatRuntime.outcome(status)) + #expect(!runtime.hasUnresolvedRun && feedback == 0 && completions == 0) + runtime.isVisible = false; runtime.isVisible = true + await runtime.reconcile() + #expect(feedback == 0 && completions == 0) + runtime.invalidate() + } + + @Test(arguments: HiddenReason.allCases) + func successfulInactiveCompletionDoesNotPlayOnReturn(_ reason: HiddenReason) async throws { + let (runtime, service, _) = ChatRuntimeTests().fixture() + var feedback = 0 + var completion: ChatCompletion? + hide(runtime, reason) + runtime.onSuccessfulVisibleCompletion = { _ in feedback += 1 } + runtime.onCompletion = { completion = $0 } + await service.setScript([.completed(try chatLifecycle(chatResponse("response", .completed)))]) + runtime.draftText = "Synthetic hidden turn"; #expect(runtime.send()) + await runtime.waitForCurrentWork() + let value = try #require(completion) + #expect(value.wasVisible == (reason != .anotherScreen)) + #expect(value.wasForeground == (reason != .background)) + #expect(feedback == 0) + runtime.isVisible = true; runtime.isForeground = true + await runtime.reconcileOnLifecycle(foreground: true) + #expect(feedback == 0) + runtime.invalidate() + } + + @Test(arguments: HiddenReason.allCases) + func returningDuringTerminalPersistenceCannotCreateDelayedFeedback(_ reason: HiddenReason) async throws { + let (runtime, service, store) = ChatRuntimeTests().fixture(), gate = ChatTestGate() + var feedback = 0 + var completion: ChatCompletion? + hide(runtime, reason) + runtime.onSuccessfulVisibleCompletion = { _ in feedback += 1 } + runtime.onCompletion = { completion = $0 } + runtime.draftText = "Synthetic saved completion"; #expect(runtime.send()) + await service.waitForPosts(); await store.configure(gate: gate) + await service.emit(.completed(try chatLifecycle(chatResponse("response", .completed)))) + await gate.waitForArrival() + // The reply is already terminal; only its durable commit is outstanding. + #expect(runtime.lastOutcome == .completed && !runtime.hasUnresolvedRun) + #expect(completion == nil && feedback == 0) + runtime.isVisible = true; runtime.isForeground = true + await gate.release(); await waitUntil { completion != nil } + let value = try #require(completion) + #expect(value.wasVisible == (reason != .anotherScreen)) + #expect(value.wasForeground == (reason != .background)) + #expect(feedback == 0) + runtime.invalidate() + } + + @Test(arguments: HiddenReason.allCases) + func leavingDuringTerminalPersistenceSuppressesFeedback(_ reason: HiddenReason) async throws { + let (runtime, service, store) = ChatRuntimeTests().fixture(), gate = ChatTestGate() + var feedback = 0, completions = 0 + runtime.isVisible = true; runtime.isForeground = true + runtime.onSuccessfulVisibleCompletion = { _ in feedback += 1 } + runtime.onCompletion = { _ in completions += 1 } + runtime.draftText = "Synthetic visible completion"; #expect(runtime.send()) + await service.waitForPosts(); await store.configure(gate: gate) + await service.emit(.completed(try chatLifecycle(chatResponse("response", .completed)))) + await gate.waitForArrival(); hide(runtime, reason) + await gate.release(); await waitUntil { completions == 1 } + #expect(feedback == 0) + runtime.isVisible = true; runtime.isForeground = true + await runtime.reconcile() + #expect(feedback == 0) + runtime.invalidate() + } + + @Test func stopRacingSuccessfulServerCompletionDoesNotRequestFeedback() async throws { + let (runtime, service, _) = ChatRuntimeTests().fixture() + var feedback = 0 + var completion: ChatCompletion? + runtime.isVisible = true; runtime.isForeground = true + runtime.onSuccessfulVisibleCompletion = { _ in feedback += 1 } + runtime.onCompletion = { completion = $0 } + await service.setScript([.created(try chatLifecycle(chatResponse("response", .inProgress)))]) + runtime.draftText = "Stopped synthetic turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() + await service.setCancelError(ResearchAPIError.http(status: 400, reason: .unspecified)) + await service.setRetrieved(chatResponse("response", .completed)) + runtime.stop(); await runtime.waitForCurrentWork() + let value = try #require(completion) + #expect(value.wasStopped && runtime.lastOutcome == .completed) + #expect(await service.cancellations == ["response"]) + #expect(feedback == 0) + runtime.invalidate() + } + + @Test func failedTerminalCommitDoesNotPublishCompletionFeedback() async throws { + let (runtime, service, store) = ChatRuntimeTests().fixture() + var feedback = 0, completions = 0 + runtime.isVisible = true; runtime.isForeground = true + runtime.onSuccessfulVisibleCompletion = { _ in feedback += 1 } + runtime.onCompletion = { _ in completions += 1 } + runtime.draftText = "Synthetic storage failure"; #expect(runtime.send()) + await service.waitForPosts(); await store.configure(fail: true) + await service.emit(.completed(try chatLifecycle(chatResponse("response", .completed)))) + await waitUntil { runtime.lastIssue == .storageUnavailable } + #expect(feedback == 0 && completions == 0) + runtime.invalidate() + } + + @Test(arguments: [false, true]) + func streamedOutputAndCompletionShareResponseIdentityWithoutRelabelingHistory(sparse: Bool) async throws { + let (runtime, service, _) = ChatRuntimeTests().fixture() + let history = [ + ConversationItem(id: "old-unlinked", type: "message", role: "assistant"), + ConversationItem(id: "old-linked", responseId: "old-response", type: "message", role: "assistant"), + ] + await service.setPage(APIPage(data: history)) + await runtime.load(chatID: "conversation") + var completion: ChatCompletion? + runtime.onCompletion = { completion = $0 } + let decoder = JSONDecoder(); decoder.keyDecodingStrategy = .convertFromSnakeCase + // Backend Responses output items omit response_id; the envelope owns that identity. + let added = try decoder.decode( + ResponseItemEvent.self, + from: Data( + #"{"output_index":0,"item":{"id":"assistant","type":"message","role":"assistant","status":"in_progress","content":[]}}"# + .utf8)) + let output = ConversationItem( + id: "assistant", type: "message", status: "completed", role: "assistant", + content: [MessageContent(type: "output_text", text: "Associated output")]) + var events: [ResponseStreamEvent] = [.created(try chatLifecycle(chatResponse("response", .inProgress)))] + if !sparse { events.append(.outputItemAdded(added)) } + events.append(.outputTextDelta(try chatDelta("Associated output"))) + events.append( + .completed(try chatLifecycle(chatResponse("response", .completed, output: sparse ? nil : [output])))) + await service.setScript(events) + runtime.draftText = "Synthetic identity turn"; #expect(runtime.send()) + await runtime.waitForCurrentWork() + let finished = try #require(completion) + let visible = try #require(runtime.messages.first { $0.id == "assistant" }) + #expect(runtime.lastOutcome == .completed && !runtime.hasUnresolvedRun) + #expect(finished.responseID == "response" && !finished.recovered) + #expect(visible.responseId == finished.responseID) + #expect(finished.assistantItems.first { $0.id == "assistant" }?.responseId == finished.responseID) + #expect(visible.content?.first?.text == "Associated output") + #expect(runtime.messages.first { $0.id == "old-unlinked" }?.responseId == nil) + #expect(runtime.messages.first { $0.id == "old-linked" }?.responseId == "old-response") + #expect(await service.posts.count == 1) + runtime.invalidate() + } + + @Test(arguments: [false, true]) + func recoveredOutputKeepsResponseIdentityThroughSparseHistory(sparse: Bool) async throws { + let (runtime, service, _) = ChatRuntimeTests().fixture() + let explicit = ConversationItem( + id: "explicit-item", responseId: "explicit-server-link", type: "message", role: "assistant") + await service.setPage(APIPage(data: [explicit])) + await runtime.load(chatID: "conversation") + var completion: ChatCompletion? + runtime.onCompletion = { completion = $0 } + await service.setScript([ + .created(try chatLifecycle(chatResponse("response", .inProgress))), + .outputTextDelta(try chatDelta("Recovered output")), + ]) + runtime.draftText = "Synthetic interrupted identity turn"; #expect(runtime.send()) + await runtime.waitForCurrentWork() + #expect(runtime.hasUnresolvedRun && completion == nil) + let output = ConversationItem( + id: "assistant", type: "message", status: "completed", role: "assistant", + content: [MessageContent(type: "output_text", text: "Recovered output")]) + var sparseExplicit = explicit + sparseExplicit.responseId = nil + await service.setRetrieved( + chatResponse("response", .completed, output: sparse ? nil : [output, sparseExplicit])) + await service.setPage( + APIPage(data: [ + output, sparseExplicit, + ConversationItem(id: "old-unlinked", type: "message", role: "assistant"), + ConversationItem(id: "old-linked", responseId: "old-response", type: "message", role: "assistant"), + ])) + await runtime.reconcile() + let finished = try #require(completion) + let visible = try #require(runtime.messages.first { $0.id == "assistant" }) + #expect(runtime.lastOutcome == .completed && !runtime.hasUnresolvedRun) + #expect(finished.recovered && finished.responseID == "response") + #expect(visible.responseId == finished.responseID) + #expect(finished.assistantItems.first { $0.id == "assistant" }?.responseId == finished.responseID) + #expect(visible.content?.first?.text == "Recovered output") + #expect(runtime.messages.first { $0.id == "explicit-item" }?.responseId == "explicit-server-link") + #expect(runtime.messages.first { $0.id == "old-unlinked" }?.responseId == nil) + #expect(runtime.messages.first { $0.id == "old-linked" }?.responseId == "old-response") + #expect(await service.posts.count == 1) + #expect(await service.retrievals == ["response"]) + runtime.invalidate() + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatDeletionFeedbackTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatDeletionFeedbackTests.swift new file mode 100644 index 000000000..191e7c56a --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatDeletionFeedbackTests.swift @@ -0,0 +1,54 @@ +import Foundation +import Testing +@testable import MapleAppCore + +@MainActor @Suite struct ChatDeletionFeedbackTests { + @Test(arguments: [false, true], [false, true]) + func deletionAdmissionFeedbackPreservesTheDraftAndNeverDispatches(fenced: Bool, enqueue: Bool) async { + let (runtime, service, _) = ChatRuntimeTests().fixture() + runtime.draftText = "Retained draft" + let snapshot = runtime.snapshot + if fenced { runtime.beginDeletionFence(.init()) } else { runtime.deletionAdmission = { _ in false } } + let accepted = enqueue ? runtime.enqueueDraft() : runtime.send() + #expect(!accepted && runtime.lastIssue == .conversationDeleting) + #expect(runtime.snapshot == snapshot && !runtime.hasUnresolvedRun) + #expect(await service.posts.isEmpty) + #expect(await service.creations == 0) + runtime.invalidate() + } + + @Test func onlyActualDiscoveryFenceChangesNotifyAndPausedQueueNeverReplays() async { + let (runtime, service, _) = ChatRuntimeTests().fixture() + runtime.draftText = "Paused queued turn"; #expect(runtime.enqueueDraft()) + runtime.draftText = "Unsent draft" + let snapshot = runtime.snapshot, first = UUID(), second = UUID() + var changes: [Set] = [] + runtime.onDeletionFenceChanged = { changes.append(runtime.deletionFenceIDs) } + runtime.beginDeletionFence(first); runtime.beginDeletionFence(first) + runtime.beginDeletionFence(second); runtime.releaseDeletionFence(UUID()) + runtime.releaseDeletionFence(first); runtime.releaseDeletionFence(first) + runtime.releaseDeletionFence(second); runtime.releaseDeletionFence(second) + #expect(changes == [[first], [first, second], [second], []]) + #expect(runtime.snapshot == snapshot && runtime.isQueuePaused && !runtime.hasUnresolvedRun) + #expect(await service.posts.isEmpty) + runtime.beginDeletionFence(first); runtime.invalidate() + #expect(runtime.onDeletionFenceChanged == nil) + runtime.releaseDeletionFence(first) + #expect(changes.count == 5) + } + + @Test func discoveryAndReleaseLeaveTheExistingRunAndQueueOwnerUntouched() async throws { + let (runtime, service, _) = ChatRuntimeTests().fixture() + runtime.draftText = "Owned run"; #expect(runtime.send()); await service.waitForPosts() + runtime.draftText = "Owned queued turn"; #expect(runtime.enqueueDraft()) + let snapshot = runtime.snapshot, fence = UUID() + var notifications = 0 + runtime.onDeletionFenceChanged = { notifications += 1 } + runtime.beginDeletionFence(fence); runtime.releaseDeletionFence(fence) + #expect(notifications == 2 && runtime.snapshot == snapshot) + #expect(runtime.hasUnresolvedRun && runtime.snapshot.pendingRun?.stopRequested == false) + #expect(await service.posts.count == 1) + #expect(await service.cancellations.isEmpty) + runtime.onDeletionFenceChanged = nil; runtime.invalidate() + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatDeletionTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatDeletionTests.swift new file mode 100644 index 000000000..99df2ff5c --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatDeletionTests.swift @@ -0,0 +1,243 @@ +import Foundation +import MapleSDK +import Testing +@testable import MapleAppCore + +@MainActor @Suite struct ChatDeletionTests { + private func pending(conversationID: String? = "conversation", responseID: String? = "response") -> ChatSnapshot { + var run = ChatPendingRun( + id: UUID(), messageID: UUID().uuidString.lowercased(), outgoingDraft: ChatDraft(text: "retained"), + options: ChatSendOptions()) + run.dispatched = true; run.responseID = responseID; run.phase = .recovering + return ChatSnapshot( + storageKey: conversationID ?? "pending:fixture", conversationID: conversationID, pendingRun: run) + } + private func fixture(_ snapshot: ChatSnapshot, hooks: ChatDeletionHooks = ChatDeletionHooks()) -> ( + ChatDeletionCoordinator, ChatFixtureService, ChatFixtureStore, AccountScope + ) { + let store = ChatFixtureStore(), service = ChatFixtureService(store: store) + let scope = AccountScope(apiOrigin: "http://127.0.0.1:3000", principalID: "fixture") + return ( + ChatDeletionCoordinator( + scope: scope, retained: [snapshot], service: service, persistence: store, hooks: hooks), service, store, + scope + ) + } + @Test func unopenedPendingChatRequiresCancelAndTerminalProofBeforeRemoval() async throws { + let snapshot = pending(), (coordinator, service, store, scope) = fixture(snapshot) + await store.seed(snapshot) + await service.setCancelError(ResearchAPIError.http(status: 400, reason: .unspecified)) + let lease = try coordinator.begin(.all) + try await coordinator.quiesce(lease, timeout: .seconds(2)) + #expect(await service.cancellations == ["response"]) + #expect(await service.retrievals == ["response"]) + let replacement = ChatRuntime( + scope: scope, service: service, persistence: store, conversationID: "conversation") + let owner = coordinator.register(replacement) + #expect(owner !== replacement && owner.isDeletionQuiescent) + #expect(!owner.send()) + try await coordinator.finish(lease, deleteScope: true) + #expect(await store.recorded("conversation") == nil) + coordinator.release(lease); coordinator.invalidate() + } + @Test func genericCancelFailureCannotUseTerminalDatabaseRowAsDeletionProof() async throws { + let (coordinator, service, _, _) = fixture(pending()) + await service.setCancelError(ResearchAPIError.http(status: 503, reason: .unspecified)) + let lease = try coordinator.begin(.all) + do { + try await coordinator.quiesce(lease, timeout: .milliseconds(300)); + Issue.record("Unverified cancellation allowed deletion") + } catch ChatDeletionError.timedOut {} + #expect(await service.retrievals.isEmpty) + #expect(!(await service.cancellations).isEmpty) + coordinator.release(lease); coordinator.invalidate() + } + @Test func missingExactPreIDItemRemainsUnresolvedWithoutReplay() async throws { + let (coordinator, service, _, _) = fixture(pending(responseID: nil)) + let lease = try coordinator.begin(.all) + do { + try await coordinator.quiesce(lease, timeout: .milliseconds(300)); + Issue.record("Missing exact item incorrectly proved rejection") + } catch ChatDeletionError.timedOut {} + #expect(await service.posts.isEmpty) + #expect(await service.creations == 0) + #expect(await service.cancellations.isEmpty) + coordinator.release(lease); coordinator.invalidate() + } + @Test func projectFenceAdmitsUnrelatedDraftAndBlocksFutureProjectCreation() async throws { + let (runtime, service, store) = ChatRuntimeTests().fixture(conversationID: nil) + let coordinator = ChatDeletionCoordinator( + scope: runtime.scope, retained: [], service: service, persistence: store) + coordinator.register(runtime) + let lease = try coordinator.begin(.project("removed", knownConversationIDs: [])) + runtime.draftText = "kept composer" + #expect(!runtime.send(options: ChatSendOptions(projectID: "removed"))) + #expect(runtime.draftText == "kept composer") + #expect(runtime.send(options: ChatSendOptions(projectID: "another"))) + await service.waitForPosts() + coordinator.release(lease); runtime.invalidate(); coordinator.invalidate() + } + @Test func inFlightCreationFencesDeletionAndLateResultNeverDispatchesResponse() async throws { + let (runtime, service, store) = ChatRuntimeTests().fixture(conversationID: nil), gate = ChatTestGate() + await service.setCreationGate(gate) + let coordinator = ChatDeletionCoordinator( + scope: runtime.scope, retained: [], service: service, persistence: store) + coordinator.register(runtime) + runtime.draftText = "retained"; #expect(runtime.send(options: ChatSendOptions(projectID: "project"))) + await gate.waitForArrival() + let lease = try coordinator.begin(.project("project", knownConversationIDs: [])) + do { + try await coordinator.quiesce(lease, timeout: .zero); Issue.record("Active creation passed deletion") + } catch ChatDeletionError.timedOut {} + await gate.release(); await runtime.waitForCurrentWork() + try await coordinator.quiesce(lease, timeout: .seconds(2)) + #expect(runtime.conversationID == "conversation" && runtime.draftText == "retained") + #expect(await service.posts.isEmpty) + coordinator.release(lease); runtime.invalidate(); coordinator.invalidate() + } + @Test func openingUnopenedDeletedChatDuringCleanupCannotRegisterReplacementOwner() async throws { + let snapshot = ChatSnapshot( + storageKey: "conversation", conversationID: "conversation", draft: ChatDraft(text: "private")) + let (coordinator, service, store, scope) = fixture(snapshot), gate = ChatTestGate() + await store.seed(snapshot); await store.holdRemoval(gate) + let lease = try coordinator.begin(.conversations(["conversation"])) + try await coordinator.quiesce(lease) + var removed: [ChatRuntime] = [] + coordinator.onRemoved = { removed.append($0) } + let finishing = Task { try await coordinator.finish(lease, deletedConversationIDs: ["conversation"]) } + await gate.waitForArrival() + let incoming = ChatRuntime(scope: scope, service: service, persistence: store, conversationID: "conversation") + let owner = coordinator.register(incoming) + #expect(owner !== incoming && coordinator.isConversationDeleted("conversation")) + await owner.load(chatID: "conversation") + #expect(owner.draftText.isEmpty && !owner.canSend) + await gate.release(); try await finishing.value + #expect(removed.count == 1 && removed.first === owner) + coordinator.release(lease) + let late = ChatRuntime(scope: scope, service: service, persistence: store, conversationID: "conversation") + let denied = coordinator.register(late) + await denied.load(chatID: "conversation"); denied.draftText = "must not revive" + #expect(!denied.canSend && denied.draftText.isEmpty) + #expect(await store.recorded("conversation") == nil) + coordinator.invalidate() + } + + @Test func projectDiscoveryDoesNotCancelUnrelatedReplyAndReleasesItsRunningQueue() async throws { + let (runtime, service, store) = ChatRuntimeTests().fixture(), gate = ChatTestGate() + let coordinator = ChatDeletionCoordinator( + scope: runtime.scope, retained: [], service: service, persistence: store) + coordinator.register(runtime, projectID: "other") + let libraryService = LibraryFixture() + await libraryService.configure(conversations: [], failDelete: true, projectDiscoveryGate: gate) + let library = LibraryModel(scope: runtime.scope, service: libraryService, deletions: coordinator) + runtime.draftText = "running"; #expect(runtime.send()); await service.waitForPosts() + runtime.draftText = "queued"; #expect(runtime.send()) + let deletion = Task { await library.deleteProject(id: "removed") } + await gate.waitForArrival() + #expect(runtime.snapshot.pendingRun?.stopRequested == false) + #expect(!runtime.isQueuePaused) + await service.emit(.completed(try chatLifecycle(chatResponse("response", .completed)))) + await runtime.waitForCurrentWork() + #expect(await service.cancellations.isEmpty) + #expect(await service.posts.count == 1) + await gate.release(); await deletion.value; await service.waitForPosts(2) + #expect(library.issue == .connection && !runtime.isQueuePaused) + #expect(await service.cancellations.isEmpty) + runtime.invalidate(); coordinator.invalidate() + } + + @Test func typedCompletionRetainsOnlyCurrentAssistantAndCanonicalTurn() async throws { + let (runtime, service, _) = ChatRuntimeTests().fixture() + await service.setPage( + APIPage(data: [ + ConversationItem( + id: "old", type: "message", role: "assistant", + content: [MessageContent(type: "output_text", text: "old reply")]) + ])) + await runtime.load(chatID: "conversation") + var completion: ChatCompletion? + runtime.isVisible = true; runtime.isForeground = true; runtime.onCompletion = { completion = $0 } + let output = ConversationItem( + id: "new", type: "message", role: "assistant", + content: [MessageContent(type: "output_text", text: "new reply")]) + await service.setScript([.completed(try chatLifecycle(chatResponse("response", .completed, output: [output])))]) + runtime.draftText = "new turn"; #expect(runtime.send()) + let messageID = try #require(runtime.snapshot.pendingRun?.messageID) + await runtime.waitForCurrentWork() + let event = try #require(completion) + #expect(event.scope == runtime.scope && event.messageID == messageID && event.responseID == "response") + #expect(event.assistantItems.map(\.id) == ["new"] && event.assistantText == "new reply") + #expect(event.wasVisible && event.wasForeground && !event.recovered && !event.wasStopped) + #expect(runtime.snapshot.pendingRun == nil) + runtime.invalidate() + } + + @Test func backgroundCompletionPublishesOnceWithoutVisibleHaptic() async throws { + let (runtime, service, _) = ChatRuntimeTests().fixture() + var completions: [String] = [], haptics = 0 + runtime.onSuccessfulCompletion = { completions.append($0) } + runtime.onSuccessfulVisibleCompletion = { _ in haptics += 1 } + await service.setScript([.completed(try chatLifecycle(chatResponse("response", .completed)))]) + runtime.draftText = "hello"; #expect(runtime.send()); await runtime.waitForCurrentWork() + await runtime.reconcile() + #expect(completions == ["conversation"] && haptics == 0) + runtime.invalidate() + } +} + +@MainActor @Suite struct ChatCreationRecoveryTests { + private let nonce = String(repeating: "a", count: 64) + private func snapshot() -> ChatSnapshot { + var run = ChatPendingRun( + id: UUID(), messageID: UUID().uuidString.lowercased(), outgoingDraft: ChatDraft(text: "safe unsent"), + options: ChatSendOptions(projectID: "project")) + run.creationID = nonce; run.creationDispatched = true; run.phase = .recovering + return ChatSnapshot(storageKey: "pending:" + run.id.uuidString.lowercased(), pendingRun: run) + } + private func conversation(_ id: String, project: String? = "project") -> Conversation { + Conversation( + id: id, object: "conversation", createdAt: 0, metadata: ["internal_native_creation_id": .string(nonce)], + projectId: project, pinned: false, lastActivityAt: 0) + } + @Test func exactCreationNonceAcrossPagesRestoresUnsentWithoutAnyPOST() async throws { + let (runtime, service, store) = ChatRuntimeTests().fixture(conversationID: nil), retained = snapshot() + await store.seed(retained); #expect(runtime.loadRetainedSnapshot(retained)) + await service.configureConversationPages([ + APIPage( + data: [ + Conversation(id: "other", object: "conversation", createdAt: 0, pinned: false, lastActivityAt: 0) + ], lastId: "other", hasMore: true), + APIPage(data: [conversation("found")]), + ]) + await runtime.reconcile() + #expect(runtime.conversationID == "found" && runtime.draftText == "safe unsent") + #expect(!runtime.hasUnresolvedRun) + #expect(await store.recorded(retained.storageKey) == nil) + #expect(await store.recorded("found")?.draft.text == "safe unsent") + #expect(await service.creations == 0) + #expect(await service.posts.isEmpty) + runtime.invalidate() + } + @Test(arguments: ["missing", "duplicate", "wrong-project", "cursor-loop"]) + func ambiguousCreationIsRetainedAndNeverRecreated(reason: String) async { + let (runtime, service, store) = ChatRuntimeTests().fixture(conversationID: nil), retained = snapshot() + await store.seed(retained); #expect(runtime.loadRetainedSnapshot(retained)) + let pages: [APIPage] + switch reason { + case "duplicate": pages = [APIPage(data: [conversation("a"), conversation("b")])] + case "wrong-project": pages = [APIPage(data: [conversation("a", project: "other")])] + case "cursor-loop": + pages = [ + APIPage(data: [conversation("a")], lastId: "a", hasMore: true), + APIPage(data: [conversation("a")], lastId: "a", hasMore: true), + ] + default: pages = [APIPage(data: [])] + } + await service.configureConversationPages(pages); await runtime.reconcile() + #expect(runtime.conversationID == nil && runtime.hasUnresolvedRun) + #expect(runtime.lastIssue == .uncertainResponse) + #expect(await service.creations == 0) + #expect(await service.posts.isEmpty) + runtime.invalidate() + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatFixtures.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatFixtures.swift new file mode 100644 index 000000000..45d3df3d8 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatFixtures.swift @@ -0,0 +1,222 @@ +import Foundation +import MapleSDK +@testable import MapleAppCore + +actor ChatTestGate { + var entered = false + var released = false + var arrivalWaiters: [CheckedContinuation] = [] + var releaseWaiters: [CheckedContinuation] = [] + func hold() async { + entered = true + arrivalWaiters.forEach { $0.resume() }; arrivalWaiters.removeAll() + if !released { await withCheckedContinuation { releaseWaiters.append($0) } } + } + func waitForArrival() async { + if !entered { await withCheckedContinuation { arrivalWaiters.append($0) } } + } + func release() { + released = true; releaseWaiters.forEach { $0.resume() }; releaseWaiters.removeAll() + } +} + +actor ChatFixtureStore: ChatPersistence { + var values: [String: ChatSnapshot] = [:] + var saved: [ChatSnapshot] = [] + var fail = false + var gate: ChatTestGate? + var retired: Set = [] + var removeGate: ChatTestGate? + func holdRemoval(_ gate: ChatTestGate) { removeGate = gate } + var dispatchSaves = 0 + var failingDispatchGate: ChatTestGate? + var failingDispatchNumber: Int? + func configure(fail: Bool = false, gate: ChatTestGate? = nil) { self.fail = fail; self.gate = gate } + func seed(_ snapshot: ChatSnapshot) { values[snapshot.storageKey] = snapshot } + func failDispatchSave(_ number: Int, gate: ChatTestGate) { + failingDispatchNumber = number; failingDispatchGate = gate + } + func load(chatID: String, scope: AccountScope) async throws -> ChatSnapshot? { values[chatID] } + func loadAll(scope: AccountScope) async throws -> [ChatSnapshot] { Array(values.values) } + func save(_ snapshot: ChatSnapshot, scope: AccountScope) async throws { + if snapshot.pendingRun?.dispatched == true, snapshot.pendingRun?.phase == .sending { + dispatchSaves += 1 + if dispatchSaves == failingDispatchNumber, let failingDispatchGate { + await failingDispatchGate.hold(); throw ChatIssue.storageUnavailable + } + } + if let gate { await gate.hold() } + guard !fail, !retired.contains(scope.generation) else { throw ChatIssue.storageUnavailable } + if (values[snapshot.storageKey]?.revision ?? 0) <= snapshot.revision { values[snapshot.storageKey] = snapshot } + saved.append(snapshot) + } + func remove(chatID: String, scope: AccountScope) async throws { + if let removeGate { await removeGate.hold() } + values.removeValue(forKey: chatID) + } + func rekey(from: String, to snapshot: ChatSnapshot, scope: AccountScope) async throws { + guard !fail, !retired.contains(scope.generation) else { throw ChatIssue.storageUnavailable } + values.removeValue(forKey: from); values[snapshot.storageKey] = snapshot; saved.append(snapshot) + } + func retire(_ scope: AccountScope) { retired.insert(scope.generation); values.removeAll() } + func recorded(_ key: String) -> ChatSnapshot? { values[key] } + func hasDurableRequest(_ request: ResponsesCreateRequest) -> Bool { + guard let run = values[request.conversation]?.pendingRun, + request.metadata?["internal_message_id"] == .string(run.messageID) + else { return false } + return run.dispatched && UUID(uuidString: run.messageID) != nil + } +} + +actor ChatFixtureService: ChatServing { + let store: ChatFixtureStore + var creationGate: ChatTestGate? + var creationError: (any Error)? + var createdMetadata: [String: JSONValue]? + var listedConversations: [APIPage] = [] + var responseGate: ChatTestGate? + var responseErrors: [any Error] = [] + var script: [ResponseStreamEvent]? + var continuation: AsyncThrowingStream.Continuation? + var posts: [ResponsesCreateRequest] = [] + var budgets: [Int] = [] + var durableAtDispatch: [Bool] = [] + var creations = 0 + var itemRequests: [String] = [] + var retrievals: [String] = [] + var cancellations: [String] = [] + var exactItem: ConversationItem? + var retrieved = chatResponse("response", .completed) + var cancelError: (any Error)? + var page = APIPage(data: []) + var pageError: (any Error)? + var pageRequests: [ListParameters] = [] + var pageGate: ChatTestGate? + func setPageGate(_ gate: ChatTestGate?) { pageGate = gate } + var postWaiters: [(Int, CheckedContinuation)] = [] + var cancellationWaiters: [CheckedContinuation] = [] + var itemWaiters: [CheckedContinuation] = [] + + init(store: ChatFixtureStore) { self.store = store } + func setCreationGate(_ value: ChatTestGate) { creationGate = value } + func setResponseGate(_ value: ChatTestGate) { responseGate = value } + func setResponseErrors(_ values: [any Error]) { responseErrors = values } + func setScript(_ value: [ResponseStreamEvent]?) { script = value } + func setExactItem(_ value: ConversationItem?) { exactItem = value } + func setRetrieved(_ value: ResearchResponse) { retrieved = value } + func setCancelError(_ value: (any Error)?) { cancelError = value } + func setPage(_ value: APIPage) { page = value } + func setPageError(_ value: (any Error)?) { pageError = value } + + func createConversation(metadata: [String: JSONValue]?, projectId: String?, pinned: Bool?) async throws + -> Conversation + { + creations += 1 + if let creationGate { await creationGate.hold() } + createdMetadata = metadata + if let creationError { throw creationError } + return Conversation( + id: "conversation", object: "conversation", createdAt: 0, metadata: metadata, projectId: projectId, + pinned: false, lastActivityAt: 0) + } + func listConversations(_ parameters: ConversationsListParameters) async throws -> APIPage { + if !listedConversations.isEmpty { return listedConversations.removeFirst() } + return APIPage(data: []) + } + func configureCreationFailure(_ error: any Error) { creationError = error } + func configureConversationPages(_ pages: [APIPage]) { listedConversations = pages } + func createResponse(_ request: ResponsesCreateRequest, maximumInferenceSends: Int) async throws + -> AsyncThrowingStream + { + durableAtDispatch.append(await store.hasDurableRequest(request)) + posts.append(request); budgets.append(maximumInferenceSends) + let pair = AsyncThrowingStream.makeStream() + continuation = pair.continuation + let ready = postWaiters.filter { $0.0 <= posts.count }; postWaiters.removeAll { $0.0 <= posts.count } + ready.forEach { $0.1.resume() } + if let responseGate { await responseGate.hold() } + if !responseErrors.isEmpty { throw responseErrors.removeFirst() } + if let script { for event in script { pair.continuation.yield(event) }; pair.continuation.finish() } + return pair.stream + } + func retrieveResponse(id: String) async throws -> ResearchResponse { retrievals.append(id); return retrieved } + func cancelResponse(id: String) async throws -> ResearchResponse { + cancellations.append(id); cancellationWaiters.forEach { $0.resume() }; cancellationWaiters.removeAll() + if let cancelError { throw cancelError } + return chatResponse(id, .cancelled) + } + func getConversationItem(conversationId: String, itemId: String) async throws -> ConversationItem { + itemRequests.append(itemId) + itemWaiters.forEach { $0.resume() }; itemWaiters.removeAll() + guard let exactItem else { throw ResearchAPIError.http(status: 404, reason: .notFound) } + return exactItem + } + func listConversationItems(conversationId: String, parameters: ListParameters) async throws -> APIPage< + ConversationItem + > { + pageRequests.append(parameters) + let captured = page + if let pageGate { await pageGate.hold() } + if let pageError { throw pageError } + return captured + } + func emit(_ event: ResponseStreamEvent) { continuation?.yield(event) } + func finish(error: (any Error)? = nil) { + if let error { continuation?.finish(throwing: error) } else { continuation?.finish() } + } + func waitForPosts(_ count: Int = 1) async { + if posts.count < count { await withCheckedContinuation { postWaiters.append((count, $0)) } } + } + func waitForCancellation() async { + if cancellations.isEmpty { await withCheckedContinuation { cancellationWaiters.append($0) } } + } + func waitForItemRequest() async { + if itemRequests.isEmpty { await withCheckedContinuation { itemWaiters.append($0) } } + } +} + +actor ChatFailingRekeyStore: ChatPersistence { + let underlying: ProtectedChatStore + let gate: ChatTestGate + let commitBeforeFailure: Bool + var failed = false + init(underlying: ProtectedChatStore, gate: ChatTestGate, commitBeforeFailure: Bool) { + self.underlying = underlying; self.gate = gate; self.commitBeforeFailure = commitBeforeFailure + } + func load(chatID: String, scope: AccountScope) async throws -> ChatSnapshot? { + try await underlying.load(chatID: chatID, scope: scope) + } + func loadAll(scope: AccountScope) async throws -> [ChatSnapshot] { try await underlying.loadAll(scope: scope) } + func save(_ snapshot: ChatSnapshot, scope: AccountScope) async throws { + try await underlying.save(snapshot, scope: scope) + } + func remove(chatID: String, scope: AccountScope) async throws { + try await underlying.remove(chatID: chatID, scope: scope) + } + func rekey(from: String, to snapshot: ChatSnapshot, scope: AccountScope) async throws { + if snapshot.conversationID != nil, !failed { + failed = true + if commitBeforeFailure { try await underlying.rekey(from: from, to: snapshot, scope: scope) } + await gate.hold() + throw ChatIssue.storageUnavailable + } + try await underlying.rekey(from: from, to: snapshot, scope: scope) + } +} + +func chatResponse(_ id: String, _ status: ResponseStatus, output: [ConversationItem]? = nil) -> ResearchResponse { + ResearchResponse(id: id, object: "response", createdAt: 0, status: status, model: "auto:quick", output: output) +} + +func chatLifecycle(_ response: ResearchResponse) throws -> ResponseLifecycleEvent { + struct Wrapper: Encodable { var response: ResearchResponse } + let encoder = JSONEncoder(); encoder.keyEncodingStrategy = .convertToSnakeCase + let decoder = JSONDecoder(); decoder.keyDecodingStrategy = .convertFromSnakeCase + return try decoder.decode(ResponseLifecycleEvent.self, from: encoder.encode(Wrapper(response: response))) +} + +func chatDelta(_ text: String, item: String = "assistant", index: Int = 0) throws -> ResponseTextEvent { + let json: [String: Any] = ["item_id": item, "output_index": 0, "content_index": index, "delta": text] + let decoder = JSONDecoder(); decoder.keyDecodingStrategy = .convertFromSnakeCase + return try decoder.decode(ResponseTextEvent.self, from: JSONSerialization.data(withJSONObject: json)) +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatLifecycleRecoveryTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatLifecycleRecoveryTests.swift new file mode 100644 index 000000000..2d7dc27b4 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatLifecycleRecoveryTests.swift @@ -0,0 +1,122 @@ +import Foundation +import MapleSDK +import Testing +@testable import MapleAppCore + +@MainActor @Suite struct ChatLifecycleRecoveryTests { + @Test func reopeningHealthyStreamDoesNotReplaceItOrSendAgain() async throws { + let (runtime, service, _) = ChatRuntimeTests().fixture() + runtime.draftText = "Outgoing"; #expect(runtime.send()) + await service.waitForPosts() + await runtime.reconcileOnLifecycle(foreground: false) + #expect(runtime.hasUnresolvedRun) + #expect(await service.retrievals.isEmpty) + #expect(await service.itemRequests.isEmpty) + #expect(await service.posts.count == 1) + runtime.invalidate() + } + + @Test func foregroundDuringInitialDurableAdmissionLeavesTheOriginalSenderRunning() async throws { + let (runtime, service, store) = ChatRuntimeTests().fixture(), gate = ChatTestGate() + await store.configure(gate: gate) + runtime.draftText = "Outgoing"; #expect(runtime.send()) + await gate.waitForArrival() + await runtime.reconcileOnLifecycle(foreground: true) + #expect(runtime.snapshot.pendingRun?.dispatched == false) + #expect(runtime.hasUnresolvedRun && runtime.snapshot.unsentTurn == nil) + #expect(await service.posts.isEmpty) + await gate.release(); await service.waitForPosts() + #expect(await service.posts.count == 1) + runtime.invalidate() + } + + @Test func foregroundRecoversExactPendingMessageAndKeepsDraftAndQueuePaused() async throws { + let (runtime, service, _) = ChatRuntimeTests().fixture() + runtime.draftText = "Outgoing"; #expect(runtime.send()) + await service.waitForPosts() + let messageID = try #require(runtime.snapshot.pendingRun?.messageID) + runtime.draftText = "Next queued"; #expect(runtime.enqueueDraft()) + runtime.draftText = "Visible draft kept"; let draft = runtime.snapshot.draft + await service.setExactItem( + ConversationItem(id: messageID, responseId: "response", type: "message", role: "user")) + await runtime.reconcileOnLifecycle(foreground: true) + #expect(runtime.lastOutcome == .completed && !runtime.hasUnresolvedRun) + #expect(runtime.snapshot.draft == draft) + #expect(runtime.queuedTurns.count == 1 && runtime.isQueuePaused) + #expect(await service.posts.count == 1) + #expect(await service.itemRequests == [messageID]) + await runtime.reconcileOnLifecycle(foreground: true) + #expect(await service.posts.count == 1) + runtime.invalidate() + } + + @Test func reopeningUncertainRunUsesExactLookupAndOfflineReconnectNeverDispatches() async throws { + let (runtime, service, _) = ChatRuntimeTests().fixture() + runtime.draftText = "Outgoing"; #expect(runtime.send()) + await service.waitForPosts() + let messageID = try #require(runtime.snapshot.pendingRun?.messageID) + await service.finish(error: URLError(.networkConnectionLost)); await runtime.waitForCurrentWork() + runtime.draftText = "Typed while unavailable"; let owner = runtime.snapshot.draft.id + runtime.isOnline = false + await runtime.reconcileOnLifecycle(foreground: true) + #expect(await service.itemRequests.isEmpty) + runtime.isOnline = true + #expect(await service.posts.count == 1) + await runtime.reconcileOnLifecycle(foreground: false) + #expect(runtime.phase == .recovering && runtime.hasUnresolvedRun) + #expect(runtime.snapshot.draft.id == owner && runtime.draftText == "Typed while unavailable") + #expect(await service.itemRequests == [messageID]) + #expect(await service.posts.count == 1) + await service.setExactItem( + ConversationItem(id: messageID, responseId: "response", type: "message", role: "user")) + await runtime.reconcileOnLifecycle(foreground: false) + #expect(runtime.lastOutcome == .completed) + #expect(await service.itemRequests == [messageID, messageID]) + #expect(await service.posts.count == 1) + runtime.invalidate() + } + + @Test func overlappingOlderPageRequestsCannotRegressCursorOrBlockComposer() async throws { + let (runtime, service, _) = ChatRuntimeTests().fixture(), gate = ChatTestGate() + await service.setPage( + APIPage( + data: [ConversationItem(id: "latest", type: "message", role: "assistant")], lastId: "cursor-one", + hasMore: true)) + await runtime.load(chatID: "conversation") + runtime.draftText = "Editable during pagination" + await service.setPage( + APIPage( + data: [ConversationItem(id: "older", type: "message", role: "assistant")], lastId: "cursor-two", + hasMore: true)) + await service.setPageGate(gate) + let first = Task { await runtime.loadOlderMessages() } + await gate.waitForArrival() + #expect(runtime.isLoadingOlder && !runtime.isLoading && runtime.canSend) + await runtime.loadOlderMessages() + #expect(await service.pageRequests.count == 2) + runtime.draftText = "Still editable" + await gate.release(); await first.value + await service.setPageGate(nil) + await service.setPage( + APIPage( + data: [ConversationItem(id: "oldest", type: "message", role: "assistant")], lastId: "cursor-three", + hasMore: false)) + await runtime.loadOlderMessages() + #expect(await service.pageRequests.map(\.after) == [nil, "cursor-one", "cursor-two"]) + #expect(runtime.messages.map(\.id) == ["oldest", "older", "latest"]) + #expect(runtime.draftText == "Still editable" && !runtime.isLoadingOlder) + runtime.invalidate() + } + + @Test func lifecycleDoesNotReplayPausedQueueWithoutPendingOwner() async { + let (runtime, service, _) = ChatRuntimeTests().fixture() + runtime.isOnline = false; runtime.draftText = "Queued offline"; #expect(runtime.enqueueDraft()) + runtime.isOnline = true + await runtime.reconcileOnLifecycle(foreground: true) + await runtime.reconcileOnLifecycle(foreground: false) + #expect(await service.posts.isEmpty) + #expect(await service.creations == 0) + #expect(runtime.queuedTurns.count == 1) + runtime.invalidate() + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatQueueTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatQueueTests.swift new file mode 100644 index 000000000..1d78220d3 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatQueueTests.swift @@ -0,0 +1,479 @@ +import Foundation +import MapleSDK +import Testing +@testable import MapleAppCore + +@MainActor @Suite struct ChatQueueTests { + private func fixture() -> (ChatRuntime, ChatFixtureService, ChatFixtureStore) { + ChatRuntimeTests().fixture() + } + + @Test func liveSuccessPromotesFIFOWithOriginalMessageUUIDAndKeepsComposer() async throws { + let (runtime, service, _) = fixture() + runtime.draftText = "First"; #expect(runtime.send()); await service.waitForPosts() + runtime.draftText = "Queued A"; #expect(runtime.send()) + runtime.draftText = "Queued C"; #expect(runtime.send()) + let firstQueued = try #require(runtime.queuedTurns.first) + runtime.draftText = "Unsubmitted B" + await service.emit(.completed(try chatLifecycle(chatResponse("first", .completed)))) + await service.waitForPosts(2) + let posts = await service.posts + #expect(posts[1].metadata?["internal_message_id"] == .string(firstQueued.messageID)) + #expect(firstQueued.messageID != firstQueued.id.uuidString.lowercased()) + #expect(runtime.snapshot.pendingRun?.queuedTurn == firstQueued) + #expect(runtime.queuedTurns.map(\.draft.text) == ["Queued C"]) + #expect(runtime.draftText == "Unsubmitted B") + #expect(await service.durableAtDispatch == [true, true]) + await service.finish(error: URLError(.networkConnectionLost)); await runtime.waitForCurrentWork() + #expect(runtime.isQueuePaused && runtime.queuedTurns.count == 1) + runtime.invalidate() + } + + enum EmptyQueuePause: CaseIterable, Sendable { case reload, offline, stop } + + @Test(arguments: EmptyQueuePause.allCases) + func freshRunAfterDrainedQueuePromotesNewFollowUp(_ pause: EmptyQueuePause) async throws { + let (original, service, store) = fixture() + var runtime = original, completions = 0 + defer { runtime.invalidate() } + runtime.onCompletion = { _ in completions += 1 } + runtime.draftText = "First run"; #expect(runtime.send()); await service.waitForPosts() + runtime.draftText = "First follow-up"; #expect(runtime.send()) + await service.emit(.completed(try chatLifecycle(chatResponse("first", .completed)))) + await service.waitForPosts(2) + await service.emit(.completed(try chatLifecycle(chatResponse("first-follow-up", .completed)))) + await waitUntil { completions == 2 } + try #require(!runtime.hasUnresolvedRun && runtime.queuedTurns.isEmpty) + await runtime.saveDraft() + + var nextPost = 3 + switch pause { + case .reload: + let scope = runtime.scope + runtime.invalidate() + runtime = ChatRuntime(scope: scope, service: service, persistence: store, conversationID: "conversation") + await runtime.load(chatID: "conversation") + runtime.onCompletion = { _ in completions += 1 } + try #require(runtime.isQueuePaused) + case .offline: + runtime.isOnline = false; runtime.isOnline = true + try #require(runtime.isQueuePaused) + case .stop: + runtime.draftText = "Stopped without a queued follow-up" + #expect(runtime.send()); await service.waitForPosts(3) + await service.emit(.created(try chatLifecycle(chatResponse("stopped", .inProgress)))) + await waitUntil { runtime.snapshot.pendingRun?.responseID == "stopped" } + runtime.stop(); await service.waitForCancellation(); await runtime.waitForCurrentWork() + try #require(runtime.lastOutcome == .cancelled) + #expect(await service.cancellations == ["stopped"]) + nextPost = 4 + } + try #require(runtime.queuedTurns.isEmpty && !runtime.hasUnresolvedRun) + + runtime.draftText = "Second run"; #expect(runtime.send()); await service.waitForPosts(nextPost) + #expect(!runtime.isQueuePaused) + runtime.draftText = "Second follow-up"; #expect(runtime.send()) + let followUp = try #require(runtime.queuedTurns.first) + runtime.draftText = "Keep this unsubmitted draft" + let heldDraft = runtime.snapshot.draft + await service.emit(.completed(try chatLifecycle(chatResponse("second", .completed)))) + await waitUntil { completions == 3 } + guard let promoted = runtime.snapshot.pendingRun else { + #expect(runtime.queuedTurns.isEmpty) + #expect(await service.posts.count == nextPost + 1) + return + } + #expect(promoted.queuedTurn == followUp && promoted.messageID == followUp.messageID) + #expect(runtime.snapshot.draft == heldDraft) + await service.waitForPosts(nextPost + 1) + #expect(await service.posts.last?.metadata?["internal_message_id"] == .string(followUp.messageID)) + await service.emit(.completed(try chatLifecycle(chatResponse("second-follow-up", .completed)))) + await waitUntil { completions == 4 } + #expect(!runtime.hasUnresolvedRun && runtime.queuedTurns.isEmpty && !runtime.isQueuePaused) + #expect(runtime.snapshot.draft == heldDraft) + #expect(await service.durableAtDispatch == Array(repeating: true, count: nextPost + 1)) + } + + @Test func stopPreservesStagedFollowUpUntilExplicitResume() async throws { + let (runtime, service, _) = fixture() + defer { runtime.invalidate() } + runtime.draftText = "Active run"; #expect(runtime.send()); await service.waitForPosts() + runtime.draftText = "Keep this staged follow-up"; #expect(runtime.send()) + let staged = try #require(runtime.queuedTurns.first) + runtime.draftText = "Keep this composer draft" + let held = runtime.snapshot.draft + await service.emit(.created(try chatLifecycle(chatResponse("stopped", .inProgress)))) + await waitUntil { runtime.snapshot.pendingRun?.responseID == "stopped" } + runtime.stop(); await service.waitForCancellation(); await runtime.waitForCurrentWork() + #expect(runtime.lastOutcome == .cancelled && !runtime.hasUnresolvedRun) + #expect(runtime.isQueuePaused && runtime.queuedTurns == [staged] && runtime.snapshot.draft == held) + #expect(await service.posts.count == 1) + #expect(await service.cancellations == ["stopped"]) + #expect(runtime.resumeQueue()); await service.waitForPosts(2) + #expect(runtime.snapshot.pendingRun?.queuedTurn == staged && runtime.snapshot.draft == held) + #expect(await service.posts.last?.metadata?["internal_message_id"] == .string(staged.messageID)) + } + + @Test(arguments: [false, true]) + func sendPreservesNonemptyPausedQueueAndWorkingEdit(_ editing: Bool) async throws { + let (runtime, service, _) = fixture() + defer { runtime.invalidate() } + runtime.isOnline = false + runtime.draftText = "Already staged"; #expect(runtime.enqueueDraft()) + let staged = try #require(runtime.queuedTurns.first) + runtime.isOnline = true + runtime.draftText = "Held composer" + if editing { + #expect(runtime.beginQueueEdit(id: staged.id)) + runtime.draftText = "Uncommitted edit" + let prior = runtime.snapshot + #expect(!runtime.send() && runtime.lastIssue == .unfinishedQueueEdit) + #expect(runtime.snapshot == prior) + } else { + #expect(runtime.send()) + #expect(runtime.queuedTurns.count == 2 && runtime.queuedTurns.first == staged) + #expect(runtime.queuedTurns.last?.draft.text == "Held composer") + } + #expect(runtime.isQueuePaused && !runtime.hasUnresolvedRun) + #expect(await service.posts.isEmpty) + } + + enum FreshRunRejection: CaseIterable, Sendable { case empty, invalidImages, accountCapacity } + + @Test(arguments: FreshRunRejection.allCases) + func rejectedFreshRunPreservesDrainedPausedQueue(_ rejection: FreshRunRejection) async throws { + let (runtime, service, _) = fixture() + defer { runtime.invalidate() } + runtime.draftText = "Staged then removed"; #expect(runtime.enqueueDraft()) + runtime.deleteQueuedTurn(id: try #require(runtime.queuedTurns.first?.id)) + try #require(runtime.queuedTurns.isEmpty && runtime.isQueuePaused) + let expectedIssue: ChatIssue + switch rejection { + case .empty: expectedIssue = .emptyDraft + case .invalidImages: + runtime.updateDraft( + ChatDraft( + id: runtime.snapshot.draft.id, text: "Preserve this invalid image draft", + images: [ChatImageAttachment(data: Data(), contentType: "image/png")])) + expectedIssue = .invalidImages + case .accountCapacity: + runtime.draftText = "Preserve this rejected draft" + runtime.canAdmitSnapshot = { _ in false } + expectedIssue = .queueLimit + } + let prior = runtime.snapshot + #expect(!runtime.send() && runtime.lastIssue == expectedIssue) + #expect(runtime.snapshot == prior && runtime.isQueuePaused && !runtime.hasUnresolvedRun) + #expect(await service.posts.isEmpty) + } + + private func waitUntil(_ condition: @MainActor () -> Bool) async { + for _ in 0..<20_000 { + if condition() { return } + await Task.yield() + } + #expect(condition()) + } + + @Test func completionDuringEditWaitsThenSaveRestoresHeldDraftAndPromotesEditedTurn() async throws { + let (runtime, service, _) = fixture() + runtime.draftText = "Active"; #expect(runtime.send()); await service.waitForPosts() + runtime.draftText = "Original A"; #expect(runtime.enqueueDraft()) + runtime.draftText = "Held B" + let item = try #require(runtime.queuedTurns.first) + #expect(runtime.beginQueueEdit(id: item.id)) + let editID = runtime.snapshot.draft.id + runtime.draftText = "Edited A" + await service.emit(.completed(try chatLifecycle(chatResponse("active", .completed)))) + await runtime.waitForCurrentWork() + #expect(runtime.isEditingQueue && runtime.queuedTurns == [item]) + #expect(await service.posts.count == 1) + #expect(runtime.saveQueueEdit()) + await service.waitForPosts(2) + #expect(runtime.snapshot.pendingRun?.outgoingDraft.text == "Edited A") + #expect(runtime.snapshot.pendingRun?.messageID == item.messageID) + #expect(runtime.draftText == "Held B") + #expect(!runtime.updateOwnedDraft(ChatDraft(id: editID, text: "Late transcription"))) + runtime.invalidate() + } + + @Test(arguments: [false, true]) + func cancelAndDeleteEditorPreserveHeldDraft(delete: Bool) async throws { + let (runtime, service, _) = fixture() + runtime.draftText = "Original A"; #expect(runtime.enqueueDraft()) + runtime.draftText = "Second C"; #expect(runtime.enqueueDraft()) + runtime.draftText = "Held B" + let item = try #require(runtime.queuedTurns.first) + #expect(runtime.beginQueueEdit(id: item.id)) + runtime.draftText = "Uncommitted A" + let retired = runtime.snapshot.draft.id + var retiredIDs: [UUID] = []; runtime.onDraftOwnerRetired = { retiredIDs.append($0) } + if delete { runtime.deleteQueuedTurn(id: item.id) } else { runtime.cancelQueueEdit() } + #expect(runtime.draftText == "Held B" && !runtime.isEditingQueue && runtime.isQueuePaused) + #expect(runtime.queuedTurns.map(\.draft.text) == (delete ? ["Second C"] : ["Original A", "Second C"])) + #expect(retiredIDs == [retired]) + #expect(!runtime.updateOwnedDraft(ChatDraft(id: retired, text: "Late"))) + #expect(await service.posts.isEmpty) + runtime.invalidate() + } + + @Test func restoredEditPreservesAllOwnersAndSaveStillRequiresExplicitResume() async throws { + let (runtime, service, store) = fixture() + runtime.draftText = "Original A"; #expect(runtime.enqueueDraft()) + runtime.draftText = "Held B" + let item = try #require(runtime.queuedTurns.first) + #expect(runtime.beginQueueEdit(id: item.id)); runtime.draftText = "Working A" + await runtime.saveDraft() + let restored = ChatRuntime( + scope: runtime.scope, service: service, persistence: store, conversationID: "conversation") + runtime.invalidate() + await restored.load(chatID: "conversation") + #expect(restored.isEditingQueue && restored.isQueuePaused && restored.draftText == "Working A") + #expect(restored.snapshot.queue?.edit?.heldDraft.text == "Held B") + #expect(restored.queuedTurns == [item]) + #expect(!restored.resumeQueue()) + #expect(restored.saveQueueEdit()) + #expect(restored.draftText == "Held B" && restored.isQueuePaused) + #expect(await service.posts.isEmpty) + #expect(restored.resumeQueue()); await service.waitForPosts() + #expect(restored.snapshot.pendingRun?.outgoingDraft.text == "Working A") + restored.invalidate() + } + + @Test func offlineCompletionAndReconnectionNeverResumeQueue() async throws { + let (runtime, service, _) = fixture() + runtime.draftText = "Active"; #expect(runtime.send()); await service.waitForPosts() + runtime.draftText = "Queued"; #expect(runtime.enqueueDraft()) + runtime.isOnline = false + await service.emit(.completed(try chatLifecycle(chatResponse("active", .completed)))) + await runtime.waitForCurrentWork() + runtime.isOnline = true + #expect(runtime.isQueuePaused && !runtime.hasUnresolvedRun) + #expect(await service.posts.count == 1) + #expect(runtime.resumeQueue()); await service.waitForPosts(2) + runtime.invalidate() + } + + @Test func authoritativeRecoveryPreservesStagedQueueUntilResume() async throws { + let (runtime, service, _) = fixture() + runtime.draftText = "Active"; #expect(runtime.send()); await service.waitForPosts() + runtime.draftText = "Queued"; #expect(runtime.enqueueDraft()) + let messageID = try #require(runtime.snapshot.pendingRun?.messageID) + await service.finish(error: URLError(.networkConnectionLost)); await runtime.waitForCurrentWork() + await service.setExactItem(ConversationItem(id: messageID, responseId: "active", type: "message", role: "user")) + await service.setRetrieved(chatResponse("active", .completed)) + await runtime.reconcile() + #expect(!runtime.hasUnresolvedRun && runtime.isQueuePaused && runtime.queuedTurns.count == 1) + #expect(await service.posts.count == 1) + runtime.invalidate() + } + + @Test func rejectedPromotedTurnReturnsToFrontWithoutOverwritingComposer() async throws { + let (runtime, service, _) = fixture() + runtime.draftText = "Rejected A"; #expect(runtime.enqueueDraft()) + runtime.draftText = "Next C"; #expect(runtime.enqueueDraft()) + let original = runtime.queuedTurns + runtime.draftText = "Held composer B" + await service.setResponseErrors([ResearchAPIError.http(status: 413, reason: .contextLimit)]) + #expect(runtime.resumeQueue()); await runtime.waitForCurrentWork() + #expect(runtime.queuedTurns == original && runtime.isQueuePaused && !runtime.hasUnresolvedRun) + #expect(runtime.draftText == "Held composer B") + #expect(await service.posts.count == 1) + runtime.invalidate() + } + + @Test func rejectedActiveQueueHeadRemainsDurableWithSixteenAlreadyWaiting() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "maple-chat-full-" + UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = ProtectedChatStore(root: directory) + let scope = AccountScope(apiOrigin: "http://127.0.0.1:3000", principalID: "fixture") + try await store.activate(scope) + let service = ChatFixtureService(store: ChatFixtureStore()) + let gate = ChatTestGate(); await service.setResponseGate(gate) + await service.setResponseErrors([ResearchAPIError.http(status: 413, reason: .contextLimit)]) + let runtime = ChatRuntime(scope: scope, service: service, persistence: store, conversationID: "conversation") + runtime.draftText = "Already admitted head"; #expect(runtime.enqueueDraft()) + let head = try #require(runtime.queuedTurns.first) + #expect(runtime.resumeQueue()); await gate.waitForArrival() + for number in 0..<16 { runtime.draftText = "Waiting \(number)"; #expect(runtime.enqueueDraft()) } + runtime.draftText = "Held B" + await gate.release(); await runtime.waitForCurrentWork() + let saved = try #require(try await store.load(chatID: "conversation", scope: scope)) + #expect(saved.queue?.items.count == 17 && saved.queue?.recoveredHeadID == head.id) + #expect(saved.draft.text == "Held B" && saved.pendingRun == nil) + #expect(runtime.lastIssue == .contextLimit && runtime.isQueuePaused) + #expect(!runtime.enqueueDraft()); #expect(runtime.draftText == "Held B") + #expect(runtime.resumeQueue()); await service.waitForPosts(2) + #expect(runtime.queuedTurns.count == 16 && runtime.snapshot.queue?.recoveredHeadID == nil) + #expect(runtime.snapshot.pendingRun?.messageID == head.messageID) + runtime.invalidate(); try await store.retire(scope) + } + + @Test(arguments: [false, true]) + func rejectionDuringAnotherQueueEditPreservesWorkingOwnerAndPosition(queued: Bool) async throws { + let (runtime, service, store) = fixture() + let gate = ChatTestGate(); await service.setResponseGate(gate) + await service.setResponseErrors([ResearchAPIError.http(status: 413, reason: .contextLimit)]) + runtime.draftText = "Rejected turn" + if queued { #expect(runtime.enqueueDraft()); #expect(runtime.resumeQueue()) } else { #expect(runtime.send()) } + await gate.waitForArrival() + runtime.draftText = "Original editor A"; #expect(runtime.enqueueDraft()) + runtime.draftText = "Held B" + let editing = try #require(runtime.queuedTurns.first) + #expect(runtime.beginQueueEdit(id: editing.id)) + let editID = runtime.snapshot.draft.id + runtime.draftText = "" + await gate.release(); await runtime.waitForCurrentWork() + #expect(runtime.isEditingQueue && runtime.snapshot.draft.id == editID && runtime.draftText.isEmpty) + #expect(runtime.snapshot.queue?.edit?.originalIndex == (queued ? 1 : 0)) + #expect(runtime.snapshot.queue?.edit?.heldDraft.text == "Held B") + if queued { + #expect(runtime.queuedTurns.map(\.draft.text) == ["Rejected turn", "Original editor A"]) + } else { + #expect(runtime.snapshot.unsentTurn?.text == "Rejected turn") + } + #expect(await store.recorded("conversation") == runtime.snapshot) + runtime.cancelQueueEdit(); #expect(runtime.draftText == "Held B") + runtime.invalidate() + } + + @Test func heldDraftDictationDoesNotBlockOtherEditorButRetiredEditorRejectsLateInsertion() async throws { + let (runtime, _, _) = fixture() + runtime.draftText = "A"; #expect(runtime.enqueueDraft()) + runtime.draftText = "B" + let heldID = runtime.snapshot.draft.id + runtime.busyDraftIDs.insert(heldID) + #expect(runtime.beginQueueEdit(id: try #require(runtime.queuedTurns.first?.id))) + var held = try #require(runtime.ownedDraft(id: heldID)); held.text += " transcribed" + #expect(runtime.updateOwnedDraft(held)) + #expect(runtime.draftText == "A") + let editID = runtime.snapshot.draft.id + runtime.busyDraftIDs.insert(editID) + #expect(!runtime.saveQueueEdit()) + runtime.busyDraftIDs.remove(editID) + #expect(runtime.saveQueueEdit()) + #expect(runtime.draftText == "B transcribed" && !runtime.canSend) + #expect(!runtime.updateOwnedDraft(ChatDraft(id: editID, text: "Too late"))) + runtime.busyDraftIDs.remove(heldID); #expect(runtime.canSend) + runtime.invalidate() + } + + @Test func rejectedTurnAndRestoreActionDoNotReplaceEmptyBusyComposer() async throws { + let (runtime, service, _) = fixture() + let gate = ChatTestGate(); await service.setResponseGate(gate) + await service.setResponseErrors([ResearchAPIError.http(status: 413, reason: .contextLimit)]) + runtime.draftText = "Rejected A"; #expect(runtime.send()); await gate.waitForArrival() + let ownerB = runtime.snapshot.draft.id + runtime.busyDraftIDs.insert(ownerB) + await gate.release(); await runtime.waitForCurrentWork() + #expect(runtime.snapshot.draft.id == ownerB && runtime.draftText.isEmpty) + #expect(runtime.snapshot.unsentTurn?.text == "Rejected A") + runtime.restoreUnsentTurn() + #expect(runtime.snapshot.draft.id == ownerB) + #expect(runtime.updateOwnedDraft(ChatDraft(id: ownerB, text: "Transcribed B"))) + runtime.busyDraftIDs.remove(ownerB) + runtime.restoreUnsentTurn() + #expect(runtime.draftText == "Rejected A" && runtime.snapshot.unsentTurn?.text == "Transcribed B") + runtime.invalidate() + } + + @Test(arguments: [false, true]) + func composerChangeDuringFailedRekeyUsesStableStorageOwner(committed: Bool) async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "maple-chat-rekey-" + UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let actual = ProtectedChatStore(root: directory) + let scope = AccountScope(apiOrigin: "http://127.0.0.1:3000", principalID: "fixture") + try await actual.activate(scope) + let gate = ChatTestGate() + let store = ChatFailingRekeyStore(underlying: actual, gate: gate, commitBeforeFailure: committed) + let service = ChatFixtureService(store: ChatFixtureStore()) + let runtime = ChatRuntime(scope: scope, service: service, persistence: store) + let storageOwner = runtime.snapshot.storageOwner + runtime.draftText = "Unsent initial turn"; #expect(runtime.send()) + await gate.waitForArrival() + runtime.draftText = "Queued original A"; #expect(runtime.enqueueDraft()) + runtime.draftText = "Held B" + #expect(runtime.beginQueueEdit(id: try #require(runtime.queuedTurns.first?.id))) + runtime.draftText = "Working edit A" + await gate.release(); await runtime.waitForCurrentWork() + let saved = try #require(try await actual.load(chatID: "conversation", scope: scope)) + #expect(saved.storageOwner == storageOwner && saved == runtime.snapshot) + #expect(saved.draft.text == "Working edit A" && saved.queue?.edit?.heldDraft.text == "Held B") + #expect(saved.unsentTurn?.text == "Unsent initial turn") + #expect(runtime.lastIssue == .storageUnavailable && !runtime.hasUnresolvedRun) + #expect(await service.posts.isEmpty) + runtime.invalidate(); try await actual.retire(scope) + } + + @Test func editBeforeConversationCreationReturnsKeepsStableMoveOwner() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "maple-chat-create-" + UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = ProtectedChatStore(root: directory) + let scope = AccountScope(apiOrigin: "http://127.0.0.1:3000", principalID: "fixture") + try await store.activate(scope) + let service = ChatFixtureService(store: ChatFixtureStore()) + let creation = ChatTestGate(), autosave = ChatTestGate() + await service.setCreationGate(creation) + let runtime = ChatRuntime( + scope: scope, service: service, persistence: store, + hooks: ChatRuntimeHooks(sleep: { _ in + await autosave.hold(); try Task.checkCancellation() + })) + runtime.draftText = "Original turn"; #expect(runtime.send()); await creation.waitForArrival() + let owner = runtime.snapshot.storageOwner + runtime.draftText = "Queued A"; #expect(runtime.enqueueDraft()) + runtime.draftText = "Held B" + #expect(runtime.beginQueueEdit(id: try #require(runtime.queuedTurns.first?.id))) + runtime.draftText = "Edited A" + runtime.stop(); await creation.release(); await runtime.waitForCurrentWork() + let saved = try #require(try await store.load(chatID: "conversation", scope: scope)) + #expect(saved.storageOwner == owner && saved == runtime.snapshot) + #expect(saved.draft.text == "Edited A" && saved.queue?.edit?.heldDraft.text == "Held B") + #expect(saved.unsentTurn?.text == "Original turn" && runtime.lastIssue == .stoppedBeforeSending) + #expect(await service.posts.isEmpty) + runtime.invalidate(); await autosave.release(); try await store.retire(scope) + } + + @Test func queueUTF8AndCountBoundsRejectWithoutChangingDraft() async { + let (runtime, service, _) = fixture() + runtime.draftText = String(repeating: "界", count: 10_922) + #expect(runtime.enqueueDraft()) + runtime.draftText = String(repeating: "界", count: 10_923) + let oversized = runtime.snapshot.draft + #expect(!runtime.enqueueDraft()); #expect(runtime.snapshot.draft == oversized) + for number in 1..<16 { runtime.draftText = "Synthetic \(number)"; #expect(runtime.enqueueDraft()) } + runtime.draftText = "Seventeenth" + let full = runtime.snapshot.draft + #expect(!runtime.enqueueDraft()); #expect(runtime.snapshot.draft == full && runtime.queuedTurns.count == 16) + #expect(await service.posts.isEmpty) + runtime.invalidate() + } + + @Test func accountAdmissionCountsSharedImagesOnceAndRejectsExtraTurnWithoutDetaching() async { + let image = ChatImageAttachment(data: Data([1, 2, 3]), contentType: "image/png") + let original = ChatQueuedTurn(draft: ChatDraft(text: "A", images: [image])) + let edited = ChatSnapshot( + draft: ChatDraft(text: "Working A", images: [image]), + queue: + ChatQueueState( + items: [original], + edit: ChatQueueEdit( + original: original, originalIndex: 0, + heldDraft: ChatDraft(text: "B", images: [image])))) + #expect(ChatResourceUsage(snapshots: [edited]).images == 1) + #expect(ChatResourceUsage(snapshots: [edited]).imageBytes == 3) + let retained = ChatSnapshot( + storageKey: "other", + queue: ChatQueueState(items: (0..<64).map { ChatQueuedTurn(draft: ChatDraft(text: "Synthetic \($0)")) })) + let (runtime, _, _) = fixture() + runtime.canAdmitSnapshot = { ChatResourceUsage(snapshots: [retained, $0]).fitsAccount } + runtime.draftText = "Exceeds account queue count" + let prior = runtime.snapshot + #expect(!runtime.enqueueDraft()) + #expect(runtime.snapshot == prior && runtime.lastIssue == .queueLimit) + runtime.invalidate() + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatRequestBudgetTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatRequestBudgetTests.swift new file mode 100644 index 000000000..b94940dad --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatRequestBudgetTests.swift @@ -0,0 +1,77 @@ +import Foundation +import MapleSDK +import Testing +@testable import MapleAppCore + +@MainActor @Suite struct ChatRequestBudgetTests { + private func imageDraft(id: UUID, byte: UInt8 = 0) -> ChatDraft { + ChatDraft( + id: id, text: "Synthetic size fixture", + images: [ + ChatImageAttachment(data: Data(repeating: byte, count: 20 * 1_024 * 1_024), contentType: "image/png") + ]) + } + + @Test(arguments: [UInt8(0), UInt8(255)]) + func exactEncodingAcceptsSupportedImageAndRejectsActualEscapedOversize(byte: UInt8) async throws { + let (runtime, service, _) = ChatRuntimeTests().fixture() + await service.setScript([.completed(try chatLifecycle(chatResponse("response", .completed)))]) + let draft = imageDraft(id: runtime.snapshot.draft.id, byte: byte) + runtime.updateDraft(draft) + #expect(runtime.send()) + #expect(runtime.snapshot.draft == draft && runtime.snapshot.pendingRun == nil) + #expect(runtime.hasUnresolvedRun && !runtime.canSend && !runtime.send()) + await runtime.waitForCurrentWork() + if byte == 0 { + #expect(await service.posts.count == 1) + #expect(runtime.lastOutcome == .completed && runtime.snapshot.draft.isEmpty) + } else { + #expect(await service.posts.isEmpty) + #expect(runtime.snapshot.draft == draft && runtime.lastIssue == .draftTooLarge) + } + runtime.invalidate() + } + + @Test func changedDraftDuringExactPreflightIsNeverSilentlySubmitted() async { + let (runtime, service, _) = ChatRuntimeTests().fixture() + runtime.updateDraft(imageDraft(id: runtime.snapshot.draft.id)) + #expect(runtime.send()) + runtime.draftText = "Edited while preparing" + await runtime.waitForCurrentWork() + #expect(runtime.draftText == "Edited while preparing" && runtime.snapshot.draft.images.count == 1) + #expect(await service.posts.isEmpty) + #expect(!runtime.hasUnresolvedRun) + runtime.invalidate() + } + + @Test func stopDuringExactPreflightPreservesOriginalOwnerWithoutPosting() async { + let (runtime, service, _) = ChatRuntimeTests().fixture() + let draft = imageDraft(id: runtime.snapshot.draft.id) + runtime.updateDraft(draft); #expect(runtime.send()) + Task { runtime.stop() } + await runtime.waitForCurrentWork() + #expect(runtime.snapshot.draft == draft && !runtime.hasUnresolvedRun) + #expect(await service.posts.isEmpty) + runtime.invalidate() + } + + @Test func queueAndEditKeepOwnersUntilExactPreflightFinishes() async throws { + let (runtime, service, _) = ChatRuntimeTests().fixture() + let draft = imageDraft(id: runtime.snapshot.draft.id) + runtime.updateDraft(draft); #expect(runtime.enqueueDraft()) + #expect(runtime.snapshot.draft == draft && runtime.queuedTurns.isEmpty) + await runtime.waitForCurrentWork() + let queued = try #require(runtime.queuedTurns.first) + runtime.draftText = "Held B" + #expect(runtime.beginQueueEdit(id: queued.id)); runtime.draftText = "Edited A" + let editor = runtime.snapshot.draft + #expect(runtime.saveQueueEdit()) + #expect(runtime.isEditingQueue && runtime.snapshot.draft == editor) + await runtime.waitForCurrentWork() + #expect(!runtime.isEditingQueue && runtime.draftText == "Held B") + #expect(runtime.queuedTurns.first?.draft.text == "Edited A") + #expect(runtime.queuedTurns.first?.draft.images == draft.images) + #expect(await service.posts.isEmpty) + runtime.invalidate() + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatRunStartedTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatRunStartedTests.swift new file mode 100644 index 000000000..5bc21d7e8 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatRunStartedTests.swift @@ -0,0 +1,53 @@ +import Foundation +import MapleSDK +import Testing +@testable import MapleAppCore + +@MainActor @Suite struct ChatRunStartedTests { + @Test(arguments: [ResponseStatus.failed, .cancelled]) + func queuedPromotionClearsBackgroundUnreadBeforeItsFailedOrCancelledRun(_ status: ResponseStatus) async throws { + let (runtime, service, _) = ChatRuntimeTests().fixture() + var unread = false, starts = 0, completions = 0 + runtime.onRunStarted = { + starts += 1; unread = false + } + runtime.onSuccessfulCompletion = { _ in + completions += 1; unread = true + } + runtime.draftText = "First run"; #expect(runtime.send()); await service.waitForPosts() + runtime.draftText = "Queued run"; #expect(runtime.enqueueDraft()) + #expect(starts == 1 && !unread) + let terminal: ResponseStreamEvent + if status == .failed { + terminal = .failed(try chatLifecycle(chatResponse("second", status))) + } else { + let decoder = JSONDecoder(); decoder.keyDecodingStrategy = .convertFromSnakeCase + terminal = .cancelled( + try decoder.decode( + ResponseCancelledEvent.self, + from: Data( + #"{"response":{"id":"second","object":"response","created_at":0,"status":"cancelled","model":"auto:quick"}}"# + .utf8))) + } + await service.setScript([.created(try chatLifecycle(chatResponse("second", .inProgress))), terminal]) + await service.emit(.completed(try chatLifecycle(chatResponse("first", .completed)))) + await service.waitForPosts(2); await runtime.waitForCurrentWork() + #expect(starts == 2 && completions == 1 && !unread) + #expect(!runtime.hasUnresolvedRun && runtime.queuedTurns.isEmpty) + #expect(runtime.lastOutcome == (status == .failed ? .failed : .cancelled)) + runtime.invalidate(); #expect(runtime.onRunStarted == nil) + } + + @Test func newConversationAndExplicitRetryNotifyOncePerAdmittedRun() async { + let (runtime, service, _) = ChatRuntimeTests().fixture(conversationID: nil) + var starts = 0 + runtime.onRunStarted = { starts += 1 } + await service.setResponseErrors([ResearchAPIError.http(status: 400, reason: .unsupportedValue)]) + runtime.draftText = "Restored after rejection"; #expect(runtime.send()); await runtime.waitForCurrentWork() + #expect(starts == 1 && !runtime.hasUnresolvedRun) + #expect(await service.creations == 1) + #expect(runtime.send()); await service.waitForPosts(2) + #expect(starts == 2) + runtime.invalidate(); #expect(runtime.onRunStarted == nil) + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatRuntimeTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatRuntimeTests.swift new file mode 100644 index 000000000..7c97d9889 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatRuntimeTests.swift @@ -0,0 +1,774 @@ +import Foundation +import MapleSDK +import Testing +@testable import MapleAppCore + +@MainActor @Suite struct ChatRuntimeTests { + func fixture(conversationID: String? = "conversation", hooks: ChatRuntimeHooks = ChatRuntimeHooks()) + -> (ChatRuntime, ChatFixtureService, ChatFixtureStore) + { + let store = ChatFixtureStore(), scope = AccountScope(apiOrigin: "http://127.0.0.1:3000", principalID: "fixture") + let service = ChatFixtureService(store: store) + return ( + ChatRuntime( + scope: scope, service: service, persistence: store, conversationID: conversationID, hooks: hooks), + service, store + ) + } + + @Test func exactUUIDIsDurableBeforeDispatchAndDuplicateTapCannotSend() async throws { + let (runtime, service, _) = fixture() + runtime.draftText = "Synthetic turn" + #expect(runtime.send()) + #expect(!runtime.send()) + await service.waitForPosts() + #expect(await service.posts.count == 1) + #expect(await service.durableAtDispatch == [true]) + let request = try #require(await service.posts.first) + #expect( + request.metadata?["internal_message_id"] == .string(try #require(runtime.snapshot.pendingRun?.messageID))) + await service.finish(error: URLError(.networkConnectionLost)) + await runtime.waitForCurrentWork() + #expect(runtime.phase == .recovering) + #expect(!runtime.send()) + runtime.invalidate() + } + + @Test func offlineDraftEditingNeverSendsOnReconnection() async { + let (runtime, service, _) = fixture() + runtime.isOnline = false; runtime.draftText = "Edited offline" + #expect(!runtime.canSend && !runtime.send()) + #expect(runtime.lastIssue == .offline) + runtime.isOnline = true + #expect(runtime.canSend) + #expect(await service.posts.isEmpty) + runtime.invalidate() + } + + @Test func aggregateImageRequestLimitRejectsBeforeChangingDraftOwnership() async { + let (runtime, service, _) = fixture() + let data = Data(repeating: 1, count: 20 * 1_024 * 1_024) + let draft = ChatDraft( + id: runtime.snapshot.draft.id, text: "Keep all images", + images: [ + ChatImageAttachment(data: data, contentType: "image/png"), + ChatImageAttachment(data: data, contentType: "image/png"), + ]) + runtime.updateDraft(draft) + #expect(!runtime.send()) + #expect(runtime.snapshot.draft == draft && !runtime.hasUnresolvedRun) + #expect(runtime.lastIssue == .draftTooLarge) + #expect(await service.posts.isEmpty) + #expect(await service.creations == 0) + runtime.invalidate() + } + + @Test func batchedTextFlushesBeforeTerminalAndVisibleCompletionOccursOnce() async throws { + let (runtime, service, _) = fixture() + var haptics = 0 + runtime.isVisible = true; runtime.onSuccessfulVisibleCompletion = { _ in haptics += 1 } + let deltas = try (0..<200).map { _ in ResponseStreamEvent.outputTextDelta(try chatDelta("x")) } + await service.setScript( + [.created(try chatLifecycle(chatResponse("response", .inProgress)))] + deltas + + [.completed(try chatLifecycle(chatResponse("response", .completed)))]) + runtime.draftText = "Synthetic turn"; #expect(runtime.send()) + await runtime.waitForCurrentWork() + #expect( + runtime.messages.first { $0.id == "assistant" }?.content?.first?.text == String(repeating: "x", count: 200)) + #expect(runtime.lastOutcome == .completed && !runtime.hasUnresolvedRun) + #expect(haptics == 1) + await runtime.reconcile(); #expect(haptics == 1) + runtime.invalidate() + } + + @Test func olderPageLoadedDuringCreatedPersistenceIsNotOverwrittenByStreamSnapshot() async throws { + let (runtime, service, store) = fixture() + await service.setPage( + APIPage( + data: [ConversationItem(id: "recent", type: "message", role: "assistant")], + lastId: "recent", hasMore: true)) + await runtime.load(chatID: "conversation") + runtime.draftText = "New turn"; #expect(runtime.send()); await service.waitForPosts() + let gate = ChatTestGate(); await store.configure(gate: gate) + await service.emit(.created(try chatLifecycle(chatResponse("response", .inProgress)))) + await gate.waitForArrival() + await service.setPage(APIPage(data: [ConversationItem(id: "older", type: "message", role: "assistant")])) + await runtime.loadOlderMessages() + #expect(runtime.messages.first?.id == "older") + await gate.release() + await service.emit(.completed(try chatLifecycle(chatResponse("response", .completed)))) + await runtime.waitForCurrentWork() + #expect(runtime.messages.first?.id == "older") + #expect(runtime.messages.contains { $0.id == "recent" }) + runtime.invalidate() + } + + enum CancelledHistoryResult: CaseIterable, Sendable, Equatable { + case cancellationError, cancelledTransport, successfulPage + } + + @Test(arguments: CancelledHistoryResult.allCases, [false, true]) + func cancelledOlderReadKeepsFeedbackAndCursor( + _ result: CancelledHistoryResult, _ existingIssue: Bool + ) async { + let (runtime, service, _) = fixture(), gate = ChatTestGate() + await service.setPage( + APIPage( + data: [ConversationItem(id: "recent", type: "message", role: "assistant")], + lastId: "recent", hasMore: true)) + await runtime.load(chatID: "conversation") + if existingIssue { + runtime.draftText = String(repeating: "x", count: 32 * 1_024 + 1) + #expect(!runtime.enqueueDraft() && runtime.lastIssue == .draftTooLarge) + } + let issue = runtime.lastIssue, issueID = runtime.lastIssueID + await service.setPage(APIPage(data: [ConversationItem(id: "older", type: "message", role: "assistant")])) + switch result { + case .cancellationError: await service.setPageError(CancellationError()) + case .cancelledTransport: await service.setPageError(URLError(.cancelled)) + case .successfulPage: break + } + await service.setPageGate(gate) + let loading = Task { await runtime.loadOlderMessages() } + await gate.waitForArrival() + if result != .cancellationError { loading.cancel() } + await gate.release(); await loading.value + #expect(runtime.messages.map(\.id) == ["recent"] && runtime.hasOlderMessages && !runtime.isLoadingOlder) + #expect(runtime.lastIssue == issue && runtime.lastIssueID == issueID) + await service.setPageGate(nil); await service.setPageError(nil) + await runtime.loadOlderMessages() + #expect(await service.pageRequests.last?.after == "recent") + #expect(runtime.messages.map(\.id) == ["older", "recent"] && !runtime.hasOlderMessages) + runtime.invalidate() + } + + @Test func ordinaryOlderReadFailureStillReportsHistoryUnavailable() async { + let (runtime, service, _) = fixture() + await service.setPage( + APIPage( + data: [ConversationItem(id: "recent", type: "message", role: "assistant")], + lastId: "recent", hasMore: true)) + await runtime.load(chatID: "conversation") + await service.setPageError(URLError(.networkConnectionLost)) + await runtime.loadOlderMessages() + #expect(runtime.lastIssue == .historyUnavailable && runtime.hasOlderMessages && !runtime.isLoadingOlder) + #expect(runtime.messages.map(\.id) == ["recent"]) + runtime.invalidate() + } + + @Test func staleCapacityRetryWriteFailureCannotChangeReplacementRecoveryDispatchState() async throws { + let (runtime, service, store) = fixture(hooks: ChatRuntimeHooks(sleep: { _ in })) + let gate = ChatTestGate(); await store.failDispatchSave(2, gate: gate) + let response = MapleResponse( + status: 503, + headers: [ + "x-opensecret-error-contract": "1", "x-opensecret-error-code": "inference_capacity", + "x-opensecret-client-replay": "safe", "retry-after": "0", + ], body: AsyncThrowingStream { $0.finish() }, inferenceSendCount: 1) + await service.setResponseErrors([try #require(OpenSecretInferenceCapacityError(response: response))]) + runtime.draftText = "Potentially accepted"; #expect(runtime.send()) + await gate.waitForArrival() + let messageID = try #require(runtime.snapshot.pendingRun?.messageID) + await service.setExactItem( + ConversationItem(id: messageID, responseId: "replacement", type: "message", role: "user")) + await service.setRetrieved(chatResponse("replacement", .inProgress)) + // Capture and await the original task before the queued actor task + // installs replacement recovery, then release its failed write. + let replacement = Task { + let recovery = Task { await runtime.reconcile() } + await service.waitForItemRequest(); await gate.release(); await recovery.value + } + await runtime.waitForCurrentWork(); await replacement.value + #expect(runtime.snapshot.pendingRun?.dispatched == true) + #expect(runtime.snapshot.pendingRun?.responseID == "replacement") + #expect(runtime.hasUnresolvedRun && runtime.phase == .recovering) + await runtime.reconcile() + #expect(runtime.hasUnresolvedRun && runtime.snapshot.pendingRun?.dispatched == true) + #expect(await service.posts.count == 1) + runtime.invalidate() + } + + @Test func preIDRecoveryUsesExactUUIDAnd404OrWrongRoleDoesNotReleaseOwnership() async throws { + let (runtime, service, _) = fixture() + var haptics = 0 + runtime.isVisible = true; runtime.onSuccessfulVisibleCompletion = { _ in haptics += 1 } + await service.setResponseErrors([URLError(.networkConnectionLost)]) + runtime.draftText = "Synthetic turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() + let messageID = try #require(runtime.snapshot.pendingRun?.messageID) + await runtime.reconcile() + #expect(runtime.hasUnresolvedRun) + await service.setExactItem( + ConversationItem(id: messageID, responseId: "wrong", type: "message", role: "assistant")) + await runtime.reconcile(); #expect(runtime.snapshot.pendingRun?.responseID == nil) + await service.setExactItem( + ConversationItem(id: messageID, responseId: "response", type: "message", role: "user")) + await service.setRetrieved( + chatResponse( + "response", .completed, + output: [ + ConversationItem( + id: "assistant", type: "message", role: "assistant", + content: [.init(type: "output_text", text: "Recovered")]) + ])) + await runtime.reconcile() + #expect(!runtime.hasUnresolvedRun && runtime.lastOutcome == .completed) + #expect(await service.posts.count == 1) + #expect(await service.itemRequests == [messageID, messageID, messageID]) + #expect(haptics == 0) + runtime.invalidate() + } + + @Test(arguments: [ResponseStatus.queued, .inProgress, .completed, .failed, .cancelled, .incomplete]) + func recoveryUsesAuthoritativeStatus(_ status: ResponseStatus) async throws { + let (runtime, service, _) = fixture() + await service.setScript([.created(try chatLifecycle(chatResponse("response", .inProgress)))]) + runtime.draftText = "Synthetic turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() + #expect(runtime.hasUnresolvedRun) + await service.setRetrieved(chatResponse("response", status)) + await runtime.reconcile() + #expect(runtime.hasUnresolvedRun == (status == .queued || status == .inProgress)) + #expect(runtime.lastOutcome == (status == .failed ? .cancelled : ChatRuntime.outcome(status))) + #expect(await service.posts.count == 1) + runtime.invalidate() + } + + @Test(arguments: [ResponseStatus.queued, .inProgress]) + func terminalEventNameCannotOverrideNonterminalAuthoritativeStatus(_ status: ResponseStatus) async throws { + let (runtime, service, _) = fixture() + await service.setScript([ + .created(try chatLifecycle(chatResponse("response", .inProgress))), + .completed(try chatLifecycle(chatResponse("response", status))), + ]) + runtime.draftText = "Synthetic turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() + #expect(runtime.hasUnresolvedRun && runtime.phase == .recovering) + #expect(runtime.lastOutcome == nil && runtime.lastIssue == .uncertainResponse) + #expect(await service.posts.count == 1) + runtime.invalidate() + } + + @Test func stopBeforeDurableAdmissionDoesNotCreateOrDispatch() async { + let (runtime, service, store) = fixture(conversationID: nil) + let gate = ChatTestGate(); await store.configure(gate: gate) + runtime.draftText = "Keep this turn"; #expect(runtime.send()) + await gate.waitForArrival(); runtime.stop(); await gate.release() + await runtime.waitForCurrentWork() + #expect(await service.creations == 0) + #expect(await service.posts.isEmpty) + #expect(runtime.draftText == "Keep this turn" && !runtime.hasUnresolvedRun) + #expect(runtime.lastIssue == .stoppedBeforeSending) + runtime.invalidate() + } + + @Test func stopDuringConversationCreationRekeysDraftWithoutResponsesPOST() async { + let (runtime, service, store) = fixture(conversationID: nil) + let gate = ChatTestGate(); await service.setCreationGate(gate) + runtime.draftText = "Keep this turn"; #expect(runtime.send()) + await gate.waitForArrival(); runtime.stop(); await gate.release() + await runtime.waitForCurrentWork() + #expect(runtime.conversationID == "conversation" && runtime.draftText == "Keep this turn") + #expect(await service.posts.isEmpty) + #expect(await store.recorded(ChatSnapshot.newChatKey) == nil) + #expect(await store.recorded("conversation")?.draft.text == "Keep this turn") + #expect(runtime.lastIssue == .stoppedBeforeSending) + runtime.invalidate() + } + + @Test func stopBeforeCreatedKeepsIntentAndCancelsExactLateResponse() async throws { + let (runtime, service, _) = fixture() + runtime.draftText = "Synthetic turn"; #expect(runtime.send()); await service.waitForPosts() + runtime.stop() + #expect(runtime.hasUnresolvedRun) + #expect(runtime.lastIssue == .stoppingWhenResponseReady) + await service.emit(.created(try chatLifecycle(chatResponse("response", .inProgress)))) + await service.waitForCancellation(); await runtime.waitForCurrentWork() + #expect(await service.cancellations == ["response"]) + #expect(runtime.lastOutcome == .cancelled && !runtime.hasUnresolvedRun) + runtime.invalidate() + } + + @Test(arguments: [400, 503]) + func cancellationRaceRequiresSuccessfulCancelOrAuthenticated400(_ status: Int) async throws { + let (runtime, service, _) = fixture() + await service.setScript([.created(try chatLifecycle(chatResponse("response", .inProgress)))]) + runtime.draftText = "Synthetic turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() + await service.setCancelError(ResearchAPIError.http(status: status, reason: .unspecified)) + await service.setRetrieved(chatResponse("response", .completed)) + runtime.stop(); await runtime.waitForCurrentWork() + #expect(runtime.hasUnresolvedRun == (status == 503)) + #expect(await service.retrievals.count == (status == 400 ? 1 : 0)) + #expect(runtime.lastIssue == (status == 503 ? .cancellationUnavailable : .responseFinishedBeforeStop)) + runtime.invalidate() + } + + @Test func storageFailurePreventsPOSTAndPreservesDraft() async { + let (runtime, service, store) = fixture() + await store.configure(fail: true) + runtime.draftText = "Unsent"; #expect(runtime.send()); await runtime.waitForCurrentWork() + #expect(await service.posts.isEmpty) + #expect(runtime.draftText == "Unsent" && runtime.lastIssue == .storageUnavailable) + runtime.invalidate() + } + + @Test func rejectedTurnCannotOverwriteNewComposerEdits() async throws { + let (runtime, service, _) = fixture() + let gate = ChatTestGate(); await service.setResponseGate(gate) + await service.setResponseErrors([ResearchAPIError.http(status: 413, reason: .contextLimit)]) + runtime.draftText = "Rejected original"; #expect(runtime.send()) + await gate.waitForArrival(); runtime.draftText = "New draft"; await gate.release() + await runtime.waitForCurrentWork() + #expect(runtime.draftText == "New draft" && runtime.snapshot.unsentTurn?.text == "Rejected original") + runtime.restoreUnsentTurn() + #expect(runtime.draftText == "Rejected original" && runtime.snapshot.unsentTurn?.text == "New draft") + runtime.invalidate() + } + + @Test func invalidatedOwnerRejectsLateConversationAndDraftWrites() async { + let (runtime, service, store) = fixture(conversationID: nil) + let gate = ChatTestGate(); await service.setCreationGate(gate) + runtime.draftText = "Old account content"; #expect(runtime.send()); await gate.waitForArrival() + let scope = runtime.scope + let countBeforeRetirement = await store.saved.count + // waitForCurrentWork captures the live task before this queued MainActor + // mutation invalidates it, so the assertions observe the late return. + Task { + runtime.invalidate(); await store.retire(scope); await gate.release() + } + await runtime.waitForCurrentWork() + #expect(runtime.messages.isEmpty && runtime.draftText.isEmpty && runtime.conversationID == nil) + #expect(await service.posts.isEmpty) + #expect(await store.saved.count == countBeforeRetirement) + #expect(await store.recorded(ChatSnapshot.newChatKey) == nil) + } + + @Test func newChatDuringCreationGetsIndependentDurableDraftOwner() async throws { + let (runtime, service, store) = fixture(conversationID: nil) + let gate = ChatTestGate(); await service.setCreationGate(gate) + runtime.draftText = "First turn"; #expect(runtime.send()) + let pendingKey = runtime.snapshot.storageKey + #expect(pendingKey.hasPrefix("pending:")) + #expect(await runtime.prepareForNewChat()) + await gate.waitForArrival() + #expect(await store.recorded(ChatSnapshot.newChatKey) == nil) + #expect(await store.recorded(pendingKey)?.pendingRun?.outgoingDraft.text == "First turn") + let fresh = ChatRuntime(scope: runtime.scope, service: service, persistence: store) + await fresh.load(chatID: nil) + fresh.draftText = "Second draft"; await fresh.saveDraft() + runtime.stop(); await gate.release(); await runtime.waitForCurrentWork() + #expect(runtime.draftText == "First turn") + #expect(fresh.draftText == "Second draft") + #expect(await store.recorded(ChatSnapshot.newChatKey)?.draft.text == "Second draft") + #expect(await store.recorded("conversation")?.draft.text == "First turn") + #expect(await store.recorded(pendingKey) == nil) + #expect(await service.posts.isEmpty) + runtime.invalidate(); fresh.invalidate() + } + + @Test func interruptedConversationCreationIsDiscoverableAndNeverAutomaticallyReplayed() async throws { + let (runtime, service, store) = fixture(conversationID: nil) + let run = ChatPendingRun( + id: UUID(), messageID: UUID().uuidString.lowercased(), + outgoingDraft: ChatDraft(text: "Unsent before conversation ID"), options: .init()) + let pending = ChatSnapshot( + storageKey: "pending:" + run.id.uuidString.lowercased(), + pendingRun: run, revision: 12) + await store.seed(pending) + let discovered = try await store.loadAll(scope: runtime.scope) + #expect(discovered == [pending]) + #expect(runtime.loadRecovered(try #require(discovered.first))) + #expect(runtime.hasUnresolvedRun) + #expect(await service.creations == 0) + await runtime.reconcile() + #expect(!runtime.hasUnresolvedRun && runtime.draftText == "Unsent before conversation ID") + #expect(await service.creations == 0) + #expect(await service.posts.isEmpty) + #expect(runtime.snapshot.revision > pending.revision) + runtime.invalidate() + } + + @Test(arguments: [false, true]) + func runtimeRekeyPreservesFreshNewChatWithProtectedStore(queued: Bool) async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("maple-chat-" + UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = ProtectedChatStore(root: directory) + let scope = AccountScope(apiOrigin: "http://127.0.0.1:3000", principalID: "fixture") + try await store.activate(scope) + let service = ChatFixtureService(store: ChatFixtureStore()) + let gate = ChatTestGate(); await service.setCreationGate(gate) + let first = ChatRuntime(scope: scope, service: service, persistence: store) + first.draftText = "First turn" + if queued { #expect(first.enqueueDraft()); #expect(first.resumeQueue()) } else { #expect(first.send()) } + #expect(await first.prepareForNewChat()) + await gate.waitForArrival() + let second = ChatRuntime(scope: scope, service: service, persistence: store) + await second.load(chatID: nil) + second.draftText = "New account draft"; await second.saveDraft() + first.stop(); await gate.release(); await first.waitForCurrentWork() + let savedFirst = try #require(try await store.load(chatID: "conversation", scope: scope)) + #expect((queued ? savedFirst.queue?.items.first?.draft.text : savedFirst.draft.text) == "First turn") + #expect(try await store.load(chatID: ChatSnapshot.newChatKey, scope: scope)?.draft.text == "New account draft") + #expect(first.lastIssue == .stoppedBeforeSending && !first.hasUnresolvedRun) + first.invalidate(); second.invalidate(); try await store.retire(scope) + } + + @Test func coldLaunchRestoresNewChatWithoutSelectingInterruptedConversation() async throws { + let (runtime, service, store) = fixture(conversationID: nil) + let draft = ChatDraft(text: "Saved New Chat") + await store.seed(ChatSnapshot(draft: draft, revision: 8)) + var run = ChatPendingRun( + id: UUID(), messageID: UUID().uuidString.lowercased(), outgoingDraft: ChatDraft(text: "Older"), + options: .init()) + run.dispatched = true + await store.seed(ChatSnapshot(storageKey: "older", conversationID: "older", pendingRun: run, revision: 10)) + await runtime.load(chatID: nil) + #expect(runtime.conversationID == nil && runtime.snapshot.draft == draft) + #expect(await service.itemRequests.isEmpty) + #expect(await service.posts.isEmpty) + #expect(runtime.snapshot.revision == 8) + runtime.invalidate() + } + + @Test(arguments: [1, 2]) + func capacityRetrySharesTransportSendBudget(_ sends: Int) async throws { + let (runtime, service, _) = fixture(hooks: ChatRuntimeHooks(sleep: { _ in })) + let response = MapleResponse( + status: 503, + headers: [ + "x-opensecret-error-contract": "1", "x-opensecret-error-code": "inference_capacity", + "x-opensecret-client-replay": "safe", "retry-after": "0", + ], body: AsyncThrowingStream { $0.finish() }, inferenceSendCount: sends) + let error = try #require(OpenSecretInferenceCapacityError(response: response)) + await service.setResponseErrors([error]) + await service.setScript([.completed(try chatLifecycle(chatResponse("response", .completed)))]) + runtime.draftText = "Synthetic turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() + #expect(await service.budgets == (sends == 1 ? [2, 1] : [2])) + if sends == 1 { + let calls = await service.posts + #expect(calls[0].metadata == calls[1].metadata && runtime.lastOutcome == .completed) + } else { + #expect(runtime.draftText == "Synthetic turn" && runtime.lastOutcome == .rejected) + #expect(runtime.lastIssue == .inferenceCapacityUnavailable) + } + runtime.invalidate() + } + + @Test(arguments: [ + (ResearchAPIError.RejectionReason.contextLimit, ChatIssue.contextLimit), + (.freeTierTokenLimit, .freeTierTokenLimit), (.usageLimit, .usageLimit), (.modelUnavailable, .modelUnavailable), + (.unsupportedValue, .unsupportedValue), (.imageDescriptionUnavailable, .imageDescriptionUnavailable), + ]) + func classifiedRejectionRestoresTextAndImagesBeforePublishingIssue( + _ reason: ResearchAPIError.RejectionReason, _ issue: ChatIssue + ) async { + let (runtime, service, _) = fixture() + let image = ChatImageAttachment(data: Data([1, 2, 3]), contentType: "image/png") + let draft = ChatDraft(id: runtime.snapshot.draft.id, text: "Restore this turn", images: [image]) + runtime.updateDraft(draft) + await service.setResponseErrors([ + ResearchAPIError.http(status: reason == .imageDescriptionUnavailable ? 503 : 400, reason: reason) + ]) + #expect(runtime.send()); await runtime.waitForCurrentWork() + #expect(runtime.lastIssue == issue && runtime.snapshot.draft == draft) + #expect(runtime.lastOutcome == .rejected && !runtime.hasUnresolvedRun) + #expect(runtime.messages.isEmpty) + runtime.invalidate() + } + + @Test func failedTerminalShowsFailureAndKeepsPartialText() async throws { + let (runtime, service, _) = fixture() + await service.setScript([ + .created(try chatLifecycle(chatResponse("response", .inProgress))), + .outputTextDelta(try chatDelta("Partial response")), + .failed(try chatLifecycle(chatResponse("response", .failed))), + ]) + runtime.draftText = "A turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() + #expect(runtime.lastOutcome == .failed && runtime.lastIssue == .generationFailed) + #expect(runtime.messages.first { $0.id == "assistant" }?.content?.first?.text == "Partial response") + #expect(!runtime.hasUnresolvedRun) + runtime.invalidate() + } + + @Test func failedStreamEventShowsFailureAndKeepsPartialText() async throws { + let (runtime, service, _) = fixture() + let failure = try JSONDecoder().decode(ResponseStreamFailure.self, from: Data(#"{"code":"server_error"}"#.utf8)) + await service.setScript([ + .created(try chatLifecycle(chatResponse("response", .inProgress))), + .outputTextDelta(try chatDelta("Partial response")), .error(failure), + ]) + runtime.draftText = "A turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() + #expect(runtime.lastOutcome == .failed && runtime.lastIssue == .generationFailed) + #expect(runtime.messages.first { $0.id == "assistant" }?.content?.first?.text == "Partial response") + runtime.invalidate() + } + + @Test(arguments: [false, true]) + func reasoningStatusTracksItsOwnCompletionBeforeTheAnswerFinishes(final: Bool) async throws { + let (runtime, service, _) = fixture() + var events: [ResponseStreamEvent] = [ + .created(try chatLifecycle(chatResponse("response", .inProgress))), + .reasoningTextDelta(try chatDelta("A thought", item: "reasoning")), + ] + if final { + let decoder = JSONDecoder(); decoder.keyDecodingStrategy = .convertFromSnakeCase + let done = try decoder.decode( + ResponseTextEvent.self, + from: Data( + #"{"item_id":"reasoning","output_index":0,"content_index":0,"text":"A finished thought"}"#.utf8)) + events.append(.reasoningTextDone(done)) + } + events.append(.outputTextDelta(try chatDelta("Answer still streaming"))) + await service.setScript(events) + runtime.draftText = "A turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() + let reasoning = try #require(runtime.messages.first { $0.id == "reasoning" }) + #expect(reasoning.status == (final ? "completed" : "in_progress")) + #expect(reasoning.content?.first?.text == (final ? "A finished thought" : "A thought")) + #expect(runtime.hasUnresolvedRun && runtime.lastOutcome == nil) + runtime.invalidate() + } + + @Test func stopMarksOnlyThisRunsOutputsIncompleteAndKeepsPartialText() async throws { + let (runtime, service, _) = fixture() + let older = ConversationItem( + id: "older", responseId: "previous", type: "message", status: "completed", role: "assistant") + await service.setPage(APIPage(data: [older])); await runtime.load(chatID: "conversation") + await service.setScript([ + .created(try chatLifecycle(chatResponse("response", .inProgress))), + .reasoningTextDelta(try chatDelta("Partial reasoning", item: "reasoning")), + .outputTextDelta(try chatDelta("Partial answer")), + ]) + runtime.draftText = "Stop this turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() + runtime.stop(); await runtime.waitForCurrentWork() + #expect(runtime.lastOutcome == .cancelled && !runtime.hasUnresolvedRun && runtime.lastIssue == nil) + #expect(runtime.messages.first { $0.id == "assistant" }?.status == "incomplete") + #expect(runtime.messages.first { $0.id == "reasoning" }?.status == "incomplete") + #expect(runtime.messages.first { $0.id == "assistant" }?.content?.first?.text == "Partial answer") + #expect(runtime.messages.first { $0.id == "older" }?.status == "completed") + runtime.invalidate() + } + + @Test func stopPreservesCompletedReasoningAndToolsWhileMarkingActiveOutputsIncomplete() async throws { + let (runtime, service, _) = fixture() + let decoder = JSONDecoder(); decoder.keyDecodingStrategy = .convertFromSnakeCase + let done = try decoder.decode( + ResponseTextEvent.self, + from: Data(#"{"item_id":"reasoning","output_index":0,"content_index":0,"text":"Finished thought"}"#.utf8)) + let search = try decoder.decode( + ResponseWebSearchEvent.self, from: Data(#"{"item_id":"search","output_index":1}"#.utf8)) + let activeTool = try decoder.decode( + ResponseItemEvent.self, + from: Data( + #"{"output_index":2,"item":{"id":"active-tool","type":"function_call","status":"searching"}}"#.utf8)) + await service.setScript([ + .created(try chatLifecycle(chatResponse("response", .inProgress))), + .reasoningTextDelta(try chatDelta("A thought", item: "reasoning")), .reasoningTextDone(done), + .webSearchInProgress(search), .webSearchCompleted(search), .outputItemAdded(activeTool), + .outputTextDelta(try chatDelta("Partial answer")), + ]) + runtime.draftText = "Stop this turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() + runtime.stop(); await runtime.waitForCurrentWork() + #expect(runtime.lastOutcome == .cancelled && !runtime.hasUnresolvedRun) + #expect(runtime.messages.first { $0.id == "reasoning" }?.status == "completed") + #expect(runtime.messages.first { $0.id == "search" }?.status == "completed") + #expect(runtime.messages.first { $0.id == "active-tool" }?.status == "incomplete") + #expect(runtime.messages.first { $0.id == "assistant" }?.status == "incomplete") + #expect(runtime.messages.first { $0.id == "assistant" }?.content?.first?.text == "Partial answer") + runtime.invalidate() + } + + @Test func incompleteTerminalKeepsPartialTextAndSettlesAsStopped() async throws { + let (runtime, service, _) = fixture() + await service.setScript([ + .created(try chatLifecycle(chatResponse("response", .inProgress))), + .outputTextDelta(try chatDelta("Partial response")), + .failed(try chatLifecycle(chatResponse("response", .incomplete))), + ]) + runtime.draftText = "A turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() + #expect(runtime.lastOutcome == .cancelled && runtime.lastIssue == nil && !runtime.hasUnresolvedRun) + #expect(runtime.messages.first { $0.id == "assistant" }?.status == "incomplete") + #expect(runtime.messages.first { $0.id == "assistant" }?.content?.first?.text == "Partial response") + runtime.invalidate() + } + + @Test func authoritativeIncompleteAssistantRetainsCancelledChipAfterTextDone() async throws { + let (runtime, service, _) = fixture() + let decoder = JSONDecoder(); decoder.keyDecodingStrategy = .convertFromSnakeCase + let done = try decoder.decode( + ResponseTextEvent.self, + from: Data(#"{"item_id":"assistant","output_index":0,"content_index":0,"text":"Partial response"}"#.utf8)) + let item = ConversationItem( + id: "assistant", type: "message", status: "incomplete", role: "assistant", + content: [.init(type: "output_text", text: "Partial response")]) + await service.setScript([ + .created(try chatLifecycle(chatResponse("response", .inProgress))), + .outputTextDelta(try chatDelta("Partial response")), .outputTextDone(done), + .failed(try chatLifecycle(chatResponse("response", .incomplete, output: [item]))), + ]) + runtime.draftText = "A turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() + #expect(runtime.lastOutcome == .cancelled && runtime.lastIssue == nil && !runtime.hasUnresolvedRun) + #expect(runtime.messages.first { $0.id == "assistant" }?.status == "incomplete") + #expect(runtime.messages.first { $0.id == "assistant" }?.content?.first?.text == "Partial response") + runtime.invalidate() + } + + @Test(arguments: [false, true]) + func failedResponseLookupStopsActiveOutputAndPreservesCompletedReasoning(stopRequested: Bool) async throws { + let (runtime, service, _) = fixture() + let decoder = JSONDecoder(); decoder.keyDecodingStrategy = .convertFromSnakeCase + let done = try decoder.decode( + ResponseTextEvent.self, + from: Data(#"{"item_id":"reasoning","output_index":0,"content_index":0,"text":"Finished thought"}"#.utf8)) + await service.setScript([ + .created(try chatLifecycle(chatResponse("response", .inProgress))), + .reasoningTextDelta(try chatDelta("A thought", item: "reasoning")), .reasoningTextDone(done), + .outputTextDelta(try chatDelta("Partial answer")), + ]) + runtime.draftText = "Interrupted turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() + await service.setRetrieved(chatResponse("response", .failed)) + if stopRequested { + await service.setCancelError(ResearchAPIError.http(status: 400, reason: .unspecified)) + runtime.stop(); await runtime.waitForCurrentWork() + } else { + await runtime.reconcile() + } + #expect(runtime.lastOutcome == .cancelled && !runtime.hasUnresolvedRun) + #expect(runtime.lastIssue == (stopRequested ? nil : .recoveredStop)) + #expect(runtime.messages.first { $0.id == "reasoning" }?.status == "completed") + #expect(runtime.messages.first { $0.id == "assistant" }?.status == "incomplete") + #expect(runtime.messages.first { $0.id == "assistant" }?.content?.first?.text == "Partial answer") + runtime.invalidate() + } + + @Test(arguments: [false, true]) + func stopUsesToolFrameStatusAndPreservesFinishedToolOutput(finished: Bool) async throws { + let (runtime, service, _) = fixture() + let decoder = JSONDecoder(); decoder.keyDecodingStrategy = .convertFromSnakeCase + let added = try decoder.decode( + ResponseItemEvent.self, + from: Data(#"{"output_index":0,"item":{"id":"tool","type":"function_call","status":"in_progress"}}"#.utf8)) + let tool = try decoder.decode( + ToolCallEvent.self, + from: Data(#"{"tool_call_id":"tool","output_index":0,"name":"search","arguments":{}}"#.utf8)) + let output = try decoder.decode( + ToolOutputEvent.self, + from: Data(#"{"tool_output_id":"result","tool_call_id":"tool","output_index":1,"output":"Done"}"#.utf8)) + var events: [ResponseStreamEvent] = [ + .created(try chatLifecycle(chatResponse("response", .inProgress))), + .outputItemAdded(added), .toolCallCreated(tool), + ] + if finished { events.append(.toolOutputCreated(output)) } + await service.setScript(events) + runtime.draftText = "Stop this turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() + #expect(runtime.messages.first { $0.id == "tool" }?.status == (finished ? "completed" : "in_progress")) + runtime.stop(); await runtime.waitForCurrentWork() + #expect(runtime.lastOutcome == .cancelled && !runtime.hasUnresolvedRun) + #expect(runtime.messages.first { $0.id == "tool" }?.status == (finished ? "completed" : "incomplete")) + if finished { #expect(runtime.messages.first { $0.id == "result" }?.status == "completed") } + runtime.invalidate() + } + + @Test(arguments: [ResponseStatus.completed, .cancelled, .failed, .incomplete]) + func recoveredTerminalPublishesItsResolutionAndKeepsQueue(_ status: ResponseStatus) async throws { + let (runtime, service, _) = fixture() + await service.setScript([.created(try chatLifecycle(chatResponse("response", .inProgress)))]) + runtime.draftText = "Interrupted turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() + runtime.draftText = "Queued turn"; #expect(runtime.enqueueDraft()) + await service.setRetrieved(chatResponse("response", status)) + await runtime.reconcile() + #expect(runtime.lastIssue == (status == .completed ? .recoveredCompletion : .recoveredStop)) + #expect(runtime.lastOutcome == (status == .completed ? .completed : .cancelled)) + #expect(runtime.queuedTurns.count == 1 && runtime.queuedTurns.first?.draft.text == "Queued turn") + #expect(runtime.isQueuePaused && !runtime.hasUnresolvedRun) + #expect(await service.posts.count == 1) + runtime.invalidate() + } + + @Test(arguments: [ResponseStatus.completed, .cancelled, .failed, .incomplete]) + func recoveredTerminalPreservesHistoryReloadFailure(_ status: ResponseStatus) async throws { + let (runtime, service, _) = fixture() + await service.setScript([ + .created(try chatLifecycle(chatResponse("response", .inProgress))), + .outputTextDelta(try chatDelta("Visible partial response")), + ]) + runtime.draftText = "Interrupted turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() + await service.setRetrieved(chatResponse("response", status)) + await service.setPageError(URLError(.networkConnectionLost)) + await runtime.reconcile() + #expect(runtime.lastIssue == .historyUnavailable && !runtime.hasUnresolvedRun) + #expect(runtime.lastOutcome == (status == .completed ? .completed : .cancelled)) + #expect(runtime.messages.first { $0.id == "assistant" }?.content?.first?.text == "Visible partial response") + runtime.invalidate() + } + + @Test(arguments: [false, true]) + func successfulHistoryRecoveryClearsItsOldFailureAndPreservesANewerComposerIssue(newerIssue: Bool) async throws { + let (runtime, service, _) = fixture() + await service.setScript([.created(try chatLifecycle(chatResponse("response", .inProgress)))]) + runtime.draftText = "Interrupted turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() + await service.setRetrieved(chatResponse("response", .inProgress)) + await service.setPageError(URLError(.networkConnectionLost)); await runtime.reconcile() + let oldIssueID = try #require(runtime.lastIssueID) + #expect(runtime.lastIssue == .historyUnavailable && runtime.hasUnresolvedRun) + await service.setPageError(nil); await service.setRetrieved(chatResponse("response", .completed)) + let gate = ChatTestGate(); await service.setPageGate(gate) + let recovery = Task { await runtime.reconcile() } + await gate.waitForArrival() + if newerIssue { + runtime.draftText = String(repeating: "x", count: 32 * 1_024 + 1) + #expect(!runtime.enqueueDraft()) + #expect(runtime.lastIssue == .draftTooLarge && runtime.lastIssueID != oldIssueID) + } + await gate.release(); await recovery.value + #expect(runtime.lastOutcome == .completed && !runtime.hasUnresolvedRun) + #expect(runtime.lastIssue == (newerIssue ? .draftTooLarge : .recoveredCompletion)) + runtime.invalidate() + } + + enum ComposerIssue: CaseIterable, Sendable { case draftTooLarge, queueLimit } + + @Test(arguments: ComposerIssue.allCases, [ResponseStatus.completed, .failed, .cancelled]) + func settlementPreservesCurrentComposerAndQueueIssue(_ failure: ComposerIssue, _ status: ResponseStatus) + async throws + { + let (runtime, service, _) = fixture() + runtime.draftText = "In-flight turn"; #expect(runtime.send()); await service.waitForPosts() + let issue: ChatIssue + switch failure { + case .draftTooLarge: + runtime.draftText = String(repeating: "x", count: 32 * 1_024 + 1) + #expect(!runtime.enqueueDraft()); issue = .draftTooLarge + case .queueLimit: + for index in 0..<16 { + runtime.draftText = "Queued \(index)"; #expect(runtime.enqueueDraft()) + } + runtime.draftText = "Retain this unsent turn"; #expect(!runtime.enqueueDraft()); issue = .queueLimit + runtime.pauseQueue() + } + let issueID = runtime.lastIssueID + #expect(runtime.lastIssue == issue) + await service.emit(.completed(try chatLifecycle(chatResponse("response", status)))) + await runtime.waitForCurrentWork() + #expect(runtime.lastIssue == issue && runtime.lastIssueID == issueID) + #expect(runtime.lastOutcome == ChatRuntime.outcome(status) && !runtime.hasUnresolvedRun) + runtime.invalidate() + } + + @Test func issueAcknowledgmentSurvivesRemountAndStaleAcknowledgmentCannotConsumeNewRejection() async throws { + let (runtime, service, _) = fixture() + #expect(runtime.lastIssueID == nil && runtime.pendingIssuePresentationID(for: .contextLimit) == nil) + await service.setResponseErrors([ResearchAPIError.http(status: 413, reason: .contextLimit)]) + runtime.draftText = "Rejected turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() + let first = try #require(runtime.pendingIssuePresentationID(for: .contextLimit)) + #expect(runtime.acknowledgeIssuePresentation(id: first)) + #expect(!runtime.acknowledgeIssuePresentation(id: first)) + #expect(runtime.pendingIssuePresentationID(for: .contextLimit) == nil) + #expect(runtime.lastIssue == .contextLimit && runtime.lastIssueID == first) + await service.setResponseErrors([ResearchAPIError.http(status: 413, reason: .contextLimit)]) + #expect(runtime.send()); await runtime.waitForCurrentWork() + let second = try #require(runtime.pendingIssuePresentationID(for: .contextLimit)) + #expect(second != first && !runtime.acknowledgeIssuePresentation(id: first)) + #expect(runtime.pendingIssuePresentationID(for: .contextLimit) == second) + #expect(runtime.acknowledgeIssuePresentation(id: second)) + #expect(runtime.pendingIssuePresentationID(for: .contextLimit) == nil) + runtime.invalidate() + #expect(!runtime.acknowledgeIssuePresentation(id: second)) + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatSendAcceptanceTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatSendAcceptanceTests.swift new file mode 100644 index 000000000..5d0eddc79 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ChatSendAcceptanceTests.swift @@ -0,0 +1,55 @@ +import Foundation +import MapleSDK +import Testing +@testable import MapleAppCore + +@MainActor @Suite struct ChatSendAcceptanceTests { + @Test func ordinarySendPublishesOnlyAfterDurableAdmissionAndNeverAgainForRecovery() async { + let (runtime, service, store) = ChatRuntimeTests().fixture() + let gate = ChatTestGate(); await store.configure(gate: gate) + var accepted: [ChatSendAcceptance] = []; runtime.onTurnAccepted = { accepted.append($0) } + runtime.draftText = "Dictated draft"; let draftID = runtime.snapshot.draft.id + #expect(runtime.send()); await gate.waitForArrival() + #expect(accepted.isEmpty) + await gate.release(); await service.waitForPosts() + #expect(accepted.count == 1 && accepted.first?.draftID == draftID) + #expect(accepted.first?.messageID == runtime.snapshot.pendingRun?.messageID) + #expect(accepted.first?.scope == runtime.scope && accepted.first?.conversationID == "conversation") + await service.finish(error: URLError(.networkConnectionLost)); await runtime.waitForCurrentWork() + await runtime.saveDraft(); await runtime.reconcile() + #expect(accepted.count == 1) + runtime.invalidate() + } + + @Test func queuedAdmissionsPublishInOrderAndPromotionDoesNotPublishAgain() async { + let (runtime, service, _) = ChatRuntimeTests().fixture() + var accepted: [ChatSendAcceptance] = []; runtime.onTurnAccepted = { accepted.append($0) } + runtime.draftText = "A"; let ownerA = runtime.snapshot.draft.id; #expect(runtime.enqueueDraft()) + runtime.draftText = "B"; let ownerB = runtime.snapshot.draft.id; #expect(runtime.enqueueDraft()) + await runtime.saveDraft() + #expect(accepted.map(\.draftID) == [ownerA, ownerB]) + #expect(accepted.map(\.messageID) == runtime.queuedTurns.map(\.messageID)) + #expect(runtime.resumeQueue()); await service.waitForPosts() + #expect(accepted.count == 2) + await service.finish(error: URLError(.networkConnectionLost)); await runtime.waitForCurrentWork() + runtime.invalidate() + } + + @Test func failedStorageDeletedQueueAndStoppedPreDispatchDoNotPublish() async throws { + let (runtime, _, store) = ChatRuntimeTests().fixture() + var accepted: [ChatSendAcceptance] = []; runtime.onTurnAccepted = { accepted.append($0) } + await store.configure(fail: true) + runtime.draftText = "Rejected"; #expect(runtime.send()); await runtime.waitForCurrentWork() + #expect(accepted.isEmpty) + await store.configure() + runtime.draftText = "Deleted queue"; #expect(runtime.enqueueDraft()) + let queued = try #require(runtime.queuedTurns.first) + runtime.deleteQueuedTurn(id: queued.id); await runtime.saveDraft() + #expect(accepted.isEmpty) + let gate = ChatTestGate(); await store.configure(gate: gate) + runtime.draftText = "Stopped"; #expect(runtime.send()); await gate.waitForArrival() + runtime.stop(); await gate.release(); await runtime.waitForCurrentWork() + #expect(accepted.isEmpty) + runtime.invalidate() + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ConversationItemPollingTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ConversationItemPollingTests.swift new file mode 100644 index 000000000..88016d6fd --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Chat/ConversationItemPollingTests.swift @@ -0,0 +1,498 @@ +import Foundation +import MapleSDK +import Testing +@testable import MapleAppCore + +private actor ConversationItemSleepRecorder { + private(set) var delays: [Duration] = [] + let gate: ChatTestGate? + let fails: Bool + + init(gate: ChatTestGate? = nil, fails: Bool = false) { self.gate = gate; self.fails = fails } + + func sleep(_ duration: Duration) async throws { + delays.append(duration) + if let gate { await gate.hold() } + if fails { throw URLError(.timedOut) } + } +} + +@MainActor @Suite struct ConversationItemPollingTests { + @Test func progressiveIntervalsRetainMinuteRefreshWhileIdle() async { + let clock = ConversationItemSleepRecorder() + var polls = 0 + await ConversationItemPolling.run( + shouldContinue: { polls < 9 }, poll: { polls += 1 }, sleep: { try await clock.sleep($0) }) + #expect(polls == 9) + #expect( + await clock.delays == [ + .seconds(2), .seconds(5), .seconds(10), .seconds(15), .seconds(20), .seconds(30), + .seconds(60), .seconds(60), .seconds(60), + ]) + } + + enum LoopRevocation: CaseIterable, Sendable { + case deselected, backgrounded, accountReplaced, taskReplaced + } + + @Test(arguments: LoopRevocation.allCases) + func ownershipChangeDuringInjectedSleepPreventsTheNextRequest(_ change: LoopRevocation) async { + let gate = ChatTestGate(), clock = ConversationItemSleepRecorder(gate: gate) + var selected = true, foreground = true, polls = 0 + var generation = UUID(), ticket = UUID() + let capturedGeneration = generation, capturedTicket = ticket + let polling = Task { + await ConversationItemPolling.run( + shouldContinue: { + selected && foreground && generation == capturedGeneration && ticket == capturedTicket + }, poll: { polls += 1 }, sleep: { try await clock.sleep($0) }) + } + await gate.waitForArrival() + switch change { + case .deselected: selected = false + case .backgrounded: foreground = false + case .accountReplaced: generation = UUID() + case .taskReplaced: ticket = UUID() + } + await gate.release(); await polling.value + #expect(polls == 0) + #expect(await clock.delays == [.seconds(2)]) + } + + @Test func cancellationIsCheckedEvenWhenInjectedSleepReturnsNormally() async { + let gate = ChatTestGate(), clock = ConversationItemSleepRecorder(gate: gate) + var polls = 0 + let polling = Task { + await ConversationItemPolling.run( + shouldContinue: { true }, poll: { polls += 1 }, sleep: { try await clock.sleep($0) }) + } + await gate.waitForArrival(); polling.cancel() + await gate.release(); await polling.value + #expect(polls == 0) + #expect(await clock.delays == [.seconds(2)]) + } + + @Test func failingClockStopsWithoutRequesting() async { + let clock = ConversationItemSleepRecorder(fails: true) + var polls = 0 + await ConversationItemPolling.run( + shouldContinue: { true }, poll: { polls += 1 }, sleep: { try await clock.sleep($0) }) + #expect(polls == 0) + #expect(await clock.delays == [.seconds(2)]) + } + + @Test func presentationOwnerRestartsPollingAfterTemporaryDiscoveryFenceRelease() async { + let (runtime, service, _) = ChatRuntimeTests().fixture() + runtime.isVisible = true + let firstSleep = ChatTestGate(), restartedSleep = ChatTestGate(), fence = UUID() + var task: Task?, ticket: UUID?, starts = 0, polls = 0 + + func updatePolling() { + ticket = nil; task?.cancel(); task = nil + guard runtime.isVisible, !runtime.isRetired, runtime.deletionFenceIDs.isEmpty else { return } + let capturedTicket = UUID(), gate = starts == 0 ? firstSleep : restartedSleep + starts += 1; ticket = capturedTicket + task = Task { + defer { if ticket == capturedTicket { ticket = nil; task = nil } } + await ConversationItemPolling.run( + shouldContinue: { + ticket == capturedTicket && runtime.isVisible && !runtime.isRetired + && runtime.deletionFenceIDs.isEmpty && polls == 0 + }, + poll: { + polls += 1; await runtime.pollNewItems() + }, + sleep: { _ in await gate.hold() }) + } + } + + runtime.onDeletionFenceChanged = { updatePolling() } + updatePolling(); await firstSleep.waitForArrival() + let firstTask = task + runtime.beginDeletionFence(fence) + await firstSleep.release(); await firstTask?.value + #expect(starts == 1 && polls == 0 && task == nil) + runtime.releaseDeletionFence(fence); await restartedSleep.waitForArrival() + #expect(starts == 2) + let restartedTask = task + await restartedSleep.release(); await restartedTask?.value + #expect(polls == 1 && task == nil) + #expect(await service.pageRequests.count == 1) + runtime.onDeletionFenceChanged = nil; runtime.invalidate() + } + + @Test func loadedHistorySeedsAscendingCursorAndInProgressTailIsFetchedAgain() async throws { + let (runtime, service, _) = ChatRuntimeTests().fixture() + await service.setPage(APIPage(data: [item("partial", status: "in_progress"), item("latest"), item("older")])) + await runtime.load(chatID: "conversation") + runtime.isVisible = true + await service.setPage(APIPage(data: [item("new"), item("tail", text: "Partial", status: "in_progress")])) + await runtime.pollNewItems() + #expect(runtime.messages.map(\.id) == ["older", "latest", "partial", "new", "tail"]) + #expect(runtime.polledMessagesRevision == 1) + await service.setPage(APIPage(data: [item("tail", text: "Finished")])) + await runtime.pollNewItems() + let requests = await service.pageRequests + #expect(requests.count == 3) + #expect(requests[1].after == "latest" && requests[1].order == .asc && requests[1].limit == 20) + #expect(requests[2].after == "new" && requests[2].order == .asc && requests[2].limit == 20) + #expect(runtime.messages.last?.content?.first?.text == "Finished") + #expect(runtime.messages.last?.status == "completed") + #expect(runtime.polledMessagesRevision == 1) + runtime.invalidate() + } + + @Test func noCursorRequestsNewestPageDescendingAndNormalizesBeforeMerging() async { + let (runtime, service, _) = ChatRuntimeTests().fixture() + runtime.isVisible = true + await service.setPage(APIPage(data: [item("newest"), item("older")])) + await runtime.pollNewItems() + #expect(runtime.messages.map(\.id) == ["older", "newest"]) + #expect(runtime.polledMessagesRevision == 1) + await service.setPage(APIPage(data: [])); await runtime.pollNewItems() + let requests = await service.pageRequests + #expect(requests[0].after == nil && requests[0].order == .desc && requests[0].limit == 20) + #expect(requests[1].after == "newest" && requests[1].order == .asc) + #expect(runtime.polledMessagesRevision == 1) + runtime.invalidate() + } + + @Test(arguments: [false, true]) + func failedInitialReadPlacesPolledHistoryBeforePendingUser(_ checkpointIncludesUser: Bool) async throws { + let (runtime, service, _) = ChatRuntimeTests().fixture() + await service.setPageError(URLError(.networkConnectionLost)); await runtime.load(chatID: "conversation") + #expect(runtime.lastIssue == .historyUnavailable && !runtime.hasOlderMessages) + runtime.isVisible = true; runtime.draftText = "Keep the entire optimistic user message" + #expect(runtime.send()); await service.waitForPosts() + let pendingID = try #require(runtime.snapshot.pendingRun?.messageID) + let snapshot = runtime.snapshot + var descending = [item("recent"), item("older")] + if checkpointIncludesUser { + descending.insert( + ConversationItem( + id: pendingID, type: "message", status: "completed", role: "user", + content: [MessageContent(type: "input_text", text: "Keep")]), at: 0) + } + await service.setPageError(nil) + await service.setPage(APIPage(data: descending, lastId: "older", hasMore: true)) + await runtime.pollNewItems() + #expect(runtime.messages.map(\.id) == ["older", "recent", pendingID]) + #expect(runtime.messages.last?.content?.first?.text == "Keep the entire optimistic user message") + #expect(runtime.snapshot == snapshot && runtime.hasUnresolvedRun && runtime.hasOlderMessages) + let poll = try #require(await service.pageRequests.last) + #expect(poll.after == nil && poll.order == .desc && poll.limit == 20) + await service.setPage(APIPage(data: [item("earlier")], lastId: "earlier", hasMore: false)) + await runtime.loadOlderMessages() + #expect(await service.pageRequests.last?.after == "older") + #expect(runtime.messages.map(\.id) == ["earlier", "older", "recent", pendingID]) + await service.finish(error: URLError(.networkConnectionLost)); await runtime.waitForCurrentWork() + runtime.invalidate() + } + + @Test(arguments: [false, true]) + func initialPollKeepsRicherLocalReplyAndItsUserBeforeCheckpointedReply(_ checkpointIncludesUser: Bool) async throws + { + let (runtime, service, _) = ChatRuntimeTests().fixture() + await service.setPageError(URLError(.networkConnectionLost)); await runtime.load(chatID: "conversation") + runtime.isVisible = true + let decoder = JSONDecoder(); decoder.keyDecodingStrategy = .convertFromSnakeCase + let added = try decoder.decode( + ResponseItemEvent.self, + from: Data( + #"{"output_index":0,"item":{"id":"assistant","type":"message","role":"assistant","status":"in_progress","content":[]}}"# + .utf8)) + await service.setScript([ + .created(try chatLifecycle(chatResponse("response", .inProgress))), + .outputItemAdded(added), + .outputTextDelta(try chatDelta("The complete locally received answer is longer than its checkpoint.")), + ]) + runtime.draftText = "Synthetic turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() + #expect(runtime.messages.last?.responseId == "response") + let pendingID = try #require(runtime.snapshot.pendingRun?.messageID) + let snapshot = runtime.snapshot, issueID = runtime.lastIssueID + var descending = [item("assistant", text: "Short"), item("history")] + if checkpointIncludesUser { + descending.insert( + ConversationItem(id: pendingID, type: "message", status: "completed", role: "user"), at: 1) + } + await service.setPageError(nil); await service.setPage(APIPage(data: descending, lastId: "history")) + await runtime.pollNewItems() + #expect(runtime.messages.map(\.id) == ["history", pendingID, "assistant"]) + #expect( + runtime.messages.last?.content?.first?.text + == "The complete locally received answer is longer than its checkpoint.") + #expect(runtime.messages.last?.status == "completed" && runtime.messages.last?.responseId == "response") + #expect(runtime.snapshot == snapshot && runtime.hasUnresolvedRun && runtime.phase == .recovering) + #expect(runtime.lastIssue == .uncertainResponse && runtime.lastIssueID == issueID) + #expect(await service.pageRequests.last?.after == nil && runtime.polledMessagesRevision == 1) + runtime.invalidate() + } + + @Test(arguments: [false, true]) + func initialPollClearsOnlyTheHistoryIssueItActuallyRecovered(_ newerIssue: Bool) async { + let (runtime, service, _) = ChatRuntimeTests().fixture(), gate = ChatTestGate() + await service.setPageError(URLError(.networkConnectionLost)); await runtime.load(chatID: "conversation") + #expect(runtime.lastIssue == .historyUnavailable) + runtime.isVisible = true + await service.setPageError(nil); await service.setPage(APIPage(data: [item("recovered")])) + await service.setPageGate(gate) + let polling = Task { await runtime.pollNewItems() } + await gate.waitForArrival() + if newerIssue { + runtime.draftText = String(repeating: "x", count: 32 * 1_024 + 1) + #expect(!runtime.enqueueDraft() && runtime.lastIssue == .draftTooLarge) + } + let issueID = runtime.lastIssueID + await gate.release(); await polling.value + #expect(runtime.messages.map(\.id) == ["recovered"]) + #expect(runtime.lastIssue == (newerIssue ? .draftTooLarge : nil)) + #expect(runtime.lastIssueID == (newerIssue ? issueID : nil)) + runtime.invalidate() + } + + @Test func noCompletedCursorDoesNotReplaceAlreadyLoadedHistoryOrderOrOlderCursor() async { + let (runtime, service, _) = ChatRuntimeTests().fixture() + await service.setPage( + APIPage( + data: [item("recent", status: "in_progress"), item("older", status: "in_progress")], + lastId: "older", hasMore: true)) + await runtime.load(chatID: "conversation"); runtime.isVisible = true + await service.setPage( + APIPage(data: [item("new", status: "in_progress"), item("recent", status: "in_progress")])) + await runtime.pollNewItems() + #expect(runtime.messages.map(\.id) == ["older", "recent", "new"] && runtime.hasOlderMessages) + #expect(await service.pageRequests.last?.after == nil) + await service.setPage(APIPage(data: [item("earlier")])) + await runtime.loadOlderMessages() + #expect(await service.pageRequests.last?.after == "older") + #expect(runtime.messages.map(\.id) == ["earlier", "older", "recent", "new"]) + runtime.invalidate() + } + + @Test func duplicateUpdatesPreserveLongerTextAndComposerFeedback() async { + let (runtime, service, _) = ChatRuntimeTests().fixture() + runtime.isVisible = true + await service.setPage(APIPage(data: [item("answer", text: "Longer existing answer")])) + await runtime.pollNewItems() + runtime.draftText = String(repeating: "x", count: 32 * 1_024 + 1) + #expect(!runtime.enqueueDraft() && runtime.lastIssue == .draftTooLarge) + let issueID = runtime.lastIssueID, snapshot = runtime.snapshot + await service.setPage(APIPage(data: [item("answer", text: "Short")])) + await runtime.pollNewItems() + #expect(runtime.messages.first?.content?.first?.text == "Longer existing answer") + #expect(runtime.lastIssue == .draftTooLarge && runtime.lastIssueID == issueID) + #expect(runtime.snapshot == snapshot && runtime.polledMessagesRevision == 1) + await service.setPageError(URLError(.networkConnectionLost)); await runtime.pollNewItems() + #expect(runtime.lastIssue == .draftTooLarge && runtime.lastIssueID == issueID) + #expect(runtime.snapshot == snapshot && runtime.polledMessagesRevision == 1) + runtime.invalidate() + } + + @Test func recoveringPollPreservesEntireRicherMessageAndReasoningContentWithServerTerminalStatus() async throws { + let (runtime, service, _) = ChatRuntimeTests().fixture() + runtime.isVisible = true + await service.setScript([.created(try chatLifecycle(chatResponse("response", .inProgress)))]) + runtime.draftText = "Synthetic turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() + let snapshot = runtime.snapshot, issueID = runtime.lastIssueID + let parts = [ + MessageContent(type: "text", text: "Long first part"), + MessageContent(type: "text", text: "Keep the second part too"), + ] + let retained = [ + ConversationItem(id: "answer", type: "message", status: "in_progress", role: "assistant", content: parts), + ConversationItem(id: "thought", type: "reasoning", status: "in_progress", content: parts), + ] + await service.setPage(APIPage(data: Array(retained.reversed()))); await runtime.pollNewItems() + await service.setPage( + APIPage(data: [ + ConversationItem( + id: "thought", type: "reasoning", status: "error", content: [.init(type: "text", text: "A")]), + ConversationItem( + id: "answer", type: "message", status: "incomplete", content: [.init(type: "text", text: "B")]), + ])) + await runtime.pollNewItems() + #expect(runtime.messages.first { $0.id == "answer" }?.content == parts) + #expect(runtime.messages.first { $0.id == "thought" }?.content == parts) + #expect(runtime.messages.first { $0.id == "answer" }?.status == "incomplete") + #expect(runtime.messages.first { $0.id == "thought" }?.status == "error") + #expect(runtime.snapshot == snapshot && runtime.lastIssueID == issueID && runtime.hasUnresolvedRun) + #expect(runtime.polledMessagesRevision == 1) + runtime.invalidate() + } + + @Test(arguments: [ + JSONValue.string("Long retained tool payload 🧪"), + JSONValue.object(["query": .string("Long retained query"), "count": .integer(20)]), + JSONValue.array([.string("Long retained page"), .string("Second retained page")]), + ]) + func recoveringPollRetainsRicherToolPayloadsAndPairingWithServerTerminalStatus(_ payload: JSONValue) async throws { + let (runtime, service, _) = ChatRuntimeTests().fixture() + runtime.isVisible = true + await service.setScript([.created(try chatLifecycle(chatResponse("response", .inProgress)))]) + runtime.draftText = "Synthetic turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() + let snapshot = runtime.snapshot, issueID = runtime.lastIssueID + let types = ["function_call", "tool_call", "function_call_output", "tool_output"] + let retained = types.map { + ConversationItem( + id: $0, type: $0, status: "in_progress", callId: "paired", name: "web_search", + arguments: payload, output: payload) + } + await service.setPage(APIPage(data: retained)); await runtime.pollNewItems() + await service.setPage( + APIPage( + data: types.enumerated().map { index, type in + ConversationItem( + id: type, type: type, status: index.isMultiple(of: 2) ? "completed" : "failed", + arguments: index.isMultiple(of: 2) ? .string("x") : nil, + output: index.isMultiple(of: 2) ? nil : .string("x")) + })) + await runtime.pollNewItems() + for (index, type) in types.enumerated() { + let tool = try #require(runtime.messages.first { $0.id == type }) + #expect(tool.arguments == payload && tool.output == payload) + #expect(tool.callId == "paired" && tool.name == "web_search") + #expect(tool.status == (index.isMultiple(of: 2) ? "completed" : "failed")) + } + #expect(runtime.snapshot == snapshot && runtime.lastIssueID == issueID && runtime.hasUnresolvedRun) + #expect(runtime.polledMessagesRevision == 1) + runtime.invalidate() + } + + enum ToolOwnerConflict: CaseIterable, Sendable { + case type, callID, responseID, name + } + + @Test(arguments: ToolOwnerConflict.allCases) + func conflictingToolOwnerNeverInheritsAnUnrelatedRetainedPayload(_ conflict: ToolOwnerConflict) async throws { + let (runtime, service, _) = ChatRuntimeTests().fixture() + runtime.isVisible = true + let retained = ConversationItem( + id: "tool", responseId: "first-response", type: "function_call", status: "completed", + callId: "first-call", name: "web_search", arguments: .string("Retained private query"), + output: .string("Retained tool output")) + await service.setPage(APIPage(data: [retained])); await runtime.pollNewItems() + var incoming = ConversationItem( + id: "tool", responseId: "first-response", type: "tool_call", status: "completed", + callId: "first-call", name: "web_search", arguments: .string("x")) + switch conflict { + case .type: incoming.type = "function_call_output" + case .callID: incoming.callId = "different-call" + case .responseID: incoming.responseId = "different-response" + case .name: incoming.name = "open_url" + } + await service.setPage(APIPage(data: [incoming])); await runtime.pollNewItems() + #expect(runtime.messages == [incoming]) + #expect(runtime.polledMessagesRevision == 1) + runtime.invalidate() + } + + @Test func overlappingTicksDoNotDuplicateRequestsOrPublishScrollTwice() async { + let (runtime, service, _) = ChatRuntimeTests().fixture() + runtime.isVisible = true + await service.setPage(APIPage(data: [item("new")])) + let gate = ChatTestGate(); await service.setPageGate(gate) + let first = Task { await runtime.pollNewItems() } + await gate.waitForArrival(); await runtime.pollNewItems() + #expect(await service.pageRequests.count == 1) + await gate.release(); await first.value + #expect(runtime.messages.map(\.id) == ["new"] && runtime.polledMessagesRevision == 1) + runtime.invalidate() + } + + @Test func replacementPollOwnsItsCursorWhenOldVisibilityRequestReturnsLater() async { + let (runtime, service, _) = ChatRuntimeTests().fixture() + runtime.isVisible = true + await service.setPage(APIPage(data: [item("late")])) + let gate = ChatTestGate(); await service.setPageGate(gate) + let old = Task { await runtime.pollNewItems() } + await gate.waitForArrival(); runtime.isVisible = false; runtime.isVisible = true + await service.setPageGate(nil); await service.setPage(APIPage(data: [item("current")])) + await runtime.pollNewItems(); await gate.release(); await old.value + #expect(runtime.messages.map(\.id) == ["current"] && runtime.polledMessagesRevision == 1) + await service.setPage(APIPage(data: [])); await runtime.pollNewItems() + #expect(await service.pageRequests.last?.after == "current") + runtime.invalidate() + } + + @Test func recoveryPageMergeDoesNotSettleOrReplaceOwnedRunFeedback() async throws { + let (runtime, service, _) = ChatRuntimeTests().fixture() + runtime.isVisible = true + var completions = 0 + runtime.onSuccessfulCompletion = { _ in completions += 1 } + await service.setScript([.created(try chatLifecycle(chatResponse("response", .inProgress)))]) + runtime.draftText = "Synthetic turn"; #expect(runtime.send()); await runtime.waitForCurrentWork() + let snapshot = runtime.snapshot, issueID = runtime.lastIssueID + #expect(runtime.phase == .recovering && runtime.lastIssue == .uncertainResponse) + await service.setPage(APIPage(data: [item("answer")])) + await runtime.pollNewItems() + #expect(runtime.snapshot == snapshot && runtime.hasUnresolvedRun && completions == 0) + #expect(runtime.lastIssue == .uncertainResponse && runtime.lastIssueID == issueID) + #expect(runtime.polledMessagesRevision == 1) + #expect(await service.retrievals.isEmpty) + #expect(await service.cancellations.isEmpty) + runtime.invalidate() + } + + enum Revocation: CaseIterable, Sendable, Equatable { + case hidden, backgrounded, offline, retired, deleted, reloaded, cancelled + } + + @Test(arguments: Revocation.allCases) + func stalePollCannotPublishAfterItsOwnershipWasRevoked(_ revocation: Revocation) async { + let (runtime, service, _) = ChatRuntimeTests().fixture() + runtime.isVisible = true + await service.setPage(APIPage(data: [item("late")])) + let gate = ChatTestGate(); await service.setPageGate(gate) + let polling = Task { await runtime.pollNewItems() } + await gate.waitForArrival() + switch revocation { + case .hidden: runtime.isVisible = false; runtime.isVisible = true + case .backgrounded: runtime.isForeground = false; runtime.isForeground = true + case .offline: runtime.isOnline = false; runtime.isOnline = true + case .retired: runtime.invalidate() + case .deleted: + let fence = UUID(); runtime.beginDeletionFence(fence); runtime.releaseDeletionFence(fence) + case .reloaded: + await service.setPageGate(nil); await service.setPage(APIPage(data: [item("replacement")])) + await runtime.load(chatID: "replacement") + case .cancelled: polling.cancel() + } + await gate.release(); await polling.value + #expect(!runtime.messages.contains { $0.id == "late" }) + #expect(runtime.polledMessagesRevision == 0) + if revocation == .reloaded { #expect(runtime.messages.map(\.id) == ["replacement"]) } + runtime.invalidate() + } + + @Test func activeStreamingSkipsPollAndAStreamStartingDuringPollRejectsLateHistory() async throws { + let (runtime, service, store) = ChatRuntimeTests().fixture() + runtime.isVisible = true + await service.setPage(APIPage(data: [item("late")])) + let gate = ChatTestGate(); await service.setPageGate(gate) + let polling = Task { await runtime.pollNewItems() } + await gate.waitForArrival() + runtime.draftText = "Synthetic turn"; #expect(runtime.send()); await service.waitForPosts() + let storageGate = ChatTestGate(); await store.configure(gate: storageGate) + // The created event publishes the stream phase before its durable write. + await service.emit(.created(try chatLifecycle(chatResponse("response", .inProgress)))) + await storageGate.waitForArrival() + #expect(runtime.phase == .streaming) + await runtime.pollNewItems() + #expect(await service.pageRequests.count == 1) + await gate.release(); await polling.value + #expect(!runtime.messages.contains { $0.id == "late" }) + #expect(runtime.polledMessagesRevision == 0 && runtime.hasUnresolvedRun) + await storageGate.release() + await service.emit(.outputTextDelta(try chatDelta("Streaming answer"))) + await service.finish(); await runtime.waitForCurrentWork() + runtime.invalidate() + } + + private func item(_ id: String, text: String = "Synthetic answer", status: String = "completed") + -> ConversationItem + { + ConversationItem( + id: id, type: "message", status: status, role: "assistant", + content: [MessageContent(type: "output_text", text: text)]) + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Chat/NewChatProjectSelectionTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Chat/NewChatProjectSelectionTests.swift new file mode 100644 index 000000000..d6f1d290a --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Chat/NewChatProjectSelectionTests.swift @@ -0,0 +1,147 @@ +import Foundation +import Testing +@testable import MapleAppCore + +@MainActor @Suite struct NewChatProjectSelectionTests { + enum Replacement: CaseIterable, Sendable, Equatable { case generation, principal, origin } + enum InvalidCurrent: CaseIterable, Sendable { + case signedOut, missingRuntime, foreignRuntime, retiredRuntime, wrongKey, assignedConversation + } + + @Test func winningSelectionKeepsExistingDraftAndQueueAndCanClearProject() async throws { + let owner = Owner(), gate = ChatTestGate() + let runtime = try #require(owner.runtime) + runtime.isOnline = false + runtime.draftText = "Queued synthetic draft" + #expect(runtime.enqueueDraft()) + runtime.draftText = "Unsent synthetic draft" + let draft = runtime.snapshot.draft, queue = runtime.queuedTurns + let selecting = Task { await owner.select("project", gate: gate) } + await gate.waitForArrival() + #expect(owner.assignments == 0) + await gate.release(); await selecting.value + #expect(owner.projects[runtime.snapshot.storageOwner] == "project") + #expect(owner.assignments == 1 && owner.runtime === runtime) + #expect(runtime.snapshot.draft == draft && runtime.queuedTurns == queue) + await owner.select(nil) + #expect(owner.projects[runtime.snapshot.storageOwner] == nil && owner.assignments == 2) + #expect(runtime.snapshot.draft == draft && runtime.queuedTurns == queue) + runtime.invalidate() + } + + @Test(arguments: Replacement.allCases) + func suspendedSelectionCannotAdoptReplacementAccount(_ replacement: Replacement) async throws { + let owner = Owner(), gate = ChatTestGate() + let oldRuntime = try #require(owner.runtime), oldScope = try #require(owner.scope) + let selecting = Task { await owner.select("old-project", gate: gate) } + await gate.waitForArrival() + let nextScope = AccountScope( + apiOrigin: replacement == .origin ? "https://replacement.example" : oldScope.apiOrigin, + principalID: replacement == .principal ? "replacement" : oldScope.principalID, + generation: replacement == .generation ? UUID() : oldScope.generation) + oldRuntime.invalidate() + let nextRuntime = Owner.makeRuntime(scope: nextScope) + nextRuntime.draftText = "Replacement draft" + owner.scope = nextScope; owner.runtime = nextRuntime + owner.projects[nextRuntime.snapshot.storageOwner] = "replacement-project" + let draft = nextRuntime.snapshot.draft + // Even an erroneously retained ticket cannot substitute a new generation. + await gate.release(); await selecting.value + #expect(owner.assignments == 0) + #expect(owner.projects[nextRuntime.snapshot.storageOwner] == "replacement-project") + #expect(nextRuntime.snapshot.draft == draft) + nextRuntime.invalidate() + } + + @Test func newerSameAccountSelectionWinsEvenWhenItReusesTheDraftRuntime() async throws { + let owner = Owner(), gate = ChatTestGate() + let runtime = try #require(owner.runtime) + runtime.draftText = "Retained draft" + let draft = runtime.snapshot.draft + let oldSelection = Task { await owner.select("old-project", gate: gate) } + await gate.waitForArrival() + await owner.select("winning-project") + #expect(owner.assignments == 1) + await gate.release(); await oldSelection.value + #expect(owner.assignments == 1 && owner.projects[runtime.snapshot.storageOwner] == "winning-project") + #expect(owner.runtime === runtime && runtime.snapshot.draft == draft) + runtime.invalidate() + } + + @Test(arguments: InvalidCurrent.allCases) + func incompleteOrForeignCurrentSelectionNeverReceivesProject(_ invalid: InvalidCurrent) async throws { + let owner = Owner(), gate = ChatTestGate() + let original = try #require(owner.runtime), scope = try #require(owner.scope) + owner.projects[original.snapshot.storageOwner] = "keep-project" + let projects = owner.projects + let selecting = Task { await owner.select("wrong-project", gate: gate) } + await gate.waitForArrival() + switch invalid { + case .signedOut: owner.scope = nil + case .missingRuntime: owner.runtime = nil + case .foreignRuntime: + owner.runtime = Owner.makeRuntime(scope: AccountScope(apiOrigin: scope.apiOrigin, principalID: "other")) + case .retiredRuntime: original.invalidate() + case .wrongKey: owner.key = "another-selection" + case .assignedConversation: owner.runtime = Owner.makeRuntime(scope: scope, conversationID: "conversation") + } + await gate.release(); await selecting.value + #expect(owner.assignments == 0 && owner.projects == projects) + owner.runtime?.invalidate(); original.invalidate() + } + + @Test(arguments: [false, true]) + func abortedOrCancelledSelectionCannotAssignEvenWhenCurrentStateStillMatches(_ cancel: Bool) async throws { + let owner = Owner(), gate = ChatTestGate() + let runtime = try #require(owner.runtime) + runtime.draftText = "Draft survives refusal" + let draft = runtime.snapshot.draft + owner.projects[runtime.snapshot.storageOwner] = "keep-project" + let selecting = Task { await owner.select("wrong-project", gate: gate, completed: cancel) } + await gate.waitForArrival() + if cancel { selecting.cancel() } + await gate.release(); await selecting.value + #expect(owner.assignments == 0 && owner.projects[runtime.snapshot.storageOwner] == "keep-project") + #expect(runtime.snapshot.draft == draft) + runtime.invalidate() + } + + @MainActor private final class Owner { + var scope: AccountScope? + var navigation = UUID() + var key = ChatSnapshot.newChatKey + var runtime: ChatRuntime? + var projects: [UUID: String] = [:] + var assignments = 0 + + init() { + let scope = AccountScope(apiOrigin: "https://fixture.example", principalID: "fixture") + self.scope = scope; runtime = Self.makeRuntime(scope: scope) + } + + static func makeRuntime(scope: AccountScope, conversationID: String? = nil) -> ChatRuntime { + let store = ChatFixtureStore(), service = ChatFixtureService(store: store) + return ChatRuntime(scope: scope, service: service, persistence: store, conversationID: conversationID) + } + + func select(_ project: String?, gate: ChatTestGate? = nil, completed: Bool = true) async { + guard let scope else { return } + await NewChatProjectSelection.perform( + scope: scope, projectID: project, + navigate: { ticket in + self.navigation = ticket + if let gate { await gate.hold() } + return completed + }, + current: { + .init( + accountScope: self.scope, navigation: self.navigation, selectedKey: self.key, + runtime: self.runtime) + }, + assign: { runtime, project in + self.assignments += 1 + self.projects[runtime.snapshot.storageOwner] = project + }) + } + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/ConfigurationTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/ConfigurationTests.swift new file mode 100644 index 000000000..09b252cb3 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/ConfigurationTests.swift @@ -0,0 +1,45 @@ +import Foundation +import Testing +@testable import MapleAppCore + +@Suite struct ConfigurationTests { + private func configuration(_ origin: String, profile: AppConfiguration.Profile = .local) throws -> AppConfiguration + { + try AppConfiguration( + profile: profile, apiURL: origin, billingURL: origin, flagsURL: origin, + authURL: origin, clientID: "00000000-0000-0000-0000-000000000001", + callbackScheme: "cloud.opensecret.maple.native.dev") + } + + @Test(arguments: ["http://127.0.0.1:3000/", "http://localhost:3000", "http://[::1]:3000"]) + func localOriginsAreExplicitLoopback(_ origin: String) throws { + let value = try configuration(origin) + #expect(value.apiURL.path.isEmpty) + #expect(value.profile == .local) + } + + @Test(arguments: [ + "http://127.0.0.1", "http://0.0.0.0:3000", "http://127.0.0.1.evil.test:3000", + "https://127.0.0.1:3000", "http://user:password@127.0.0.1:3000", + "http://127.0.0.1:3000/path", "http://127.0.0.1:3000?key=value", + "http://127.0.0.1:3000#fragment", "http://127.0.0.1:0", "http://127.0.0.1:65536", + " http://127.0.0.1:3000", "http://%31%32%37.0.0.1:3000", + ]) + func rejectAmbiguousOrRemoteLocalOrigins(_ origin: String) { + #expect(throws: ConfigurationError.invalidOrigin) { try configuration(origin) } + } + + @Test func hostedProfilesCannotUseCleartext() throws { + #expect(throws: ConfigurationError.invalidOrigin) { + try configuration("http://127.0.0.1:3000", profile: .production) + } + #expect(try configuration("https://api.example.test", profile: .development).apiURL.scheme == "https") + } + + @Test func sameAccountReloginCannotReuseAppLease() { + let first = AccountScope(apiOrigin: "http://127.0.0.1:3000", principalID: "fixture") + let second = AccountScope(apiOrigin: first.apiOrigin, principalID: first.principalID) + #expect(first != second) + #expect(first.generation != second.generation) + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Dictation/DictationAdmissionOwnerTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Dictation/DictationAdmissionOwnerTests.swift new file mode 100644 index 000000000..5c691ad06 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Dictation/DictationAdmissionOwnerTests.swift @@ -0,0 +1,27 @@ +import Testing +@testable import MapleAppCore + +@MainActor struct DictationAdmissionOwnerTests { + @Test func admissionPublishesExactOwnerOnceBeforePermissionAndActivity() async throws { + let (runtime, coordinator, capture, _, _) = dictationFixture() + runtime.draftText = "Owned B" + let expected = DictationDraftOwner( + scope: runtime.scope, storageOwnerID: runtime.snapshot.storageOwner, draftID: runtime.snapshot.draft.id) + let permission = ChatTestGate(); capture.permissionGate = permission; capture.permission = false + var owners: [DictationDraftOwner] = [], events: [String] = [] + coordinator.onCaptureAdmitted = { + owners.append($0); events.append("owner") + } + coordinator.onActivityChange = { _ in events.append("activity") } + #expect(coordinator.start(runtime: runtime)) + await permission.waitForArrival() + #expect(owners == [expected] && events.prefix(2) == ["owner", "activity"]) + #expect(!coordinator.start(runtime: runtime)) + #expect(owners.count == 1) + await permission.release() + try await dictationWait { coordinator.statuses.isEmpty } + #expect(capture.starts == 0 && owners == [expected]) + #expect(runtime.draftText == "Owned B") + coordinator.invalidate(); runtime.invalidate() + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Dictation/DictationFixtures.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Dictation/DictationFixtures.swift new file mode 100644 index 000000000..3b19b017b --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Dictation/DictationFixtures.swift @@ -0,0 +1,168 @@ +import Foundation +import Testing +@testable import MapleAppCore + +@MainActor final class DictationFixtureCapture: DictationCapturing { + var permission = true + var permissionGate: ChatTestGate? + var active: UUID? + var starts = 0 + var stops = 0 + var failStart = false + var limits: (() -> Void)? + var pcm = Data([0, 0, 1, 0, 2, 0]) + func requestPermission() async -> Bool { + if let permissionGate { await permissionGate.hold() } + return permission + } + func start(id: UUID, maximumFrames: Int, onLimit: @escaping @MainActor @Sendable () -> Void) throws { + #expect(active == nil && maximumFrames == DictationAudio.maximumFrames) + if failStart { throw DictationIssue.captureUnavailable } + active = id; starts += 1; limits = onLimit + } + func stop(id: UUID) throws -> Data { + #expect(active == id); active = nil; stops += 1; return pcm + } + func cancel(id: UUID) { if active == id { active = nil } } +} + +actor DictationFixtureTranscriber: DictationTranscribing { + private struct CallWaiter { + let count: Int + let continuation: CheckedContinuation + let timer: Task + } + var clips: [Data] = [] + var pending: [Int: CheckedContinuation] = [:] + private var callWaiters: [UUID: CallWaiter] = [:] + func transcribe(wav: Data) async throws -> String { + let index = clips.count; clips.append(wav) + return try await withCheckedThrowingContinuation { + pending[index] = $0 + // A waiter may complete the call immediately after resuming. Publish + // the arrival only once its result continuation is ready as well. + for (id, waiter) in callWaiters where clips.count >= waiter.count { + guard let completed = callWaiters.removeValue(forKey: id) else { continue } + completed.timer.cancel() + completed.continuation.resume() + } + } + } + func complete(_ index: Int = 0, text: String = "spoken words", fails: Bool = false) { + if fails { + pending.removeValue(forKey: index)?.resume(throwing: URLError(.notConnectedToInternet)) + } else { + pending.removeValue(forKey: index)?.resume(returning: text) + } + } + func waitForCalls(_ count: Int = 1, timeout: Duration = .seconds(60)) async throws { + try Task.checkCancellation() + guard clips.count < count else { return } + let id = UUID() + try await withTaskCancellationHandler { + try Task.checkCancellation() + try await withCheckedThrowingContinuation { continuation in + if clips.count >= count { + continuation.resume() + } else { + // Register the actor-owned deadline alongside its waiter, + // so even an immediate deadline cannot race installation. + let timer = Task.detached { [weak self] in + do { try await Task.sleep(for: timeout) } catch { return } + await self?.expireCallWaiter(id) + } + callWaiters[id] = CallWaiter(count: count, continuation: continuation, timer: timer) + } + } + } onCancel: { + Task { await self.cancelCallWaiter(id) } + } + } + + private func expireCallWaiter(_ id: UUID) { + guard let waiter = callWaiters.removeValue(forKey: id) else { return } + waiter.timer.cancel() + waiter.continuation.resume( + throwing: DictationFixtureWaitFailure.transcriptionTimedOut(expected: waiter.count, received: clips.count)) + } + + private func cancelCallWaiter(_ id: UUID) { + guard let waiter = callWaiters.removeValue(forKey: id) else { return } + waiter.timer.cancel() + waiter.continuation.resume(throwing: CancellationError()) + } +} + +enum DictationFixtureWaitFailure: Error, Equatable { + case transcriptionTimedOut(expected: Int, received: Int), transitionTimedOut +} + +@MainActor func dictationWait(_ condition: () -> Bool, timeout: Duration = .seconds(60)) async throws { + let clock = ContinuousClock(), deadline = ContinuousClock.now.advanced(by: timeout) + while !condition() { + try Task.checkCancellation() + guard clock.now < deadline else { throw DictationFixtureWaitFailure.transitionTimedOut } + // Native state has no fixture event hook. Suspend between observations + // instead of spending the VM's executor budget on repeated yields. + try await clock.sleep(until: min(deadline, clock.now.advanced(by: .milliseconds(2)))) + } +} + +@MainActor func dictationFixture() -> ( + ChatRuntime, DictationCoordinator, DictationFixtureCapture, DictationFixtureTranscriber, ChatFixtureStore +) { + let (runtime, _, store) = ChatRuntimeTests().fixture() + let capture = DictationFixtureCapture(), service = DictationFixtureTranscriber() + let coordinator = DictationCoordinator(scope: runtime.scope, capture: capture, transcriber: service) + runtime.onDraftOwnerChanging = { [weak coordinator, weak runtime] id in + guard let runtime else { return }; coordinator?.ownerWillChange(runtime: runtime, draftID: id) + } + runtime.onDraftOwnerRetired = { [weak coordinator, weak runtime] id in + guard let runtime else { return }; coordinator?.ownerRetired(runtime: runtime, draftID: id) + } + return (runtime, coordinator, capture, service, store) +} + +@MainActor func dictationEditor( + _ runtime: ChatRuntime, _ coordinator: DictationCoordinator, offset: Int, + marked: NSRange? = nil, draftID: UUID? = nil +) { + let owner = draftID ?? runtime.snapshot.draft.id + guard let draft = runtime.ownedDraft(id: owner) else { return } + coordinator.updateEditor( + runtime: runtime, draftID: owner, + state: DictationEditorState( + text: draft.text, selectionUTF16: NSRange(location: offset, length: 0), markedRangeUTF16: marked)) +} + +@Suite struct DictationFixtureWaitTests { + @Test func arrivalsReleaseCountWaitersWithResultContinuationsReady() async throws { + let service = DictationFixtureTranscriber() + let secondArrival = Task { try await service.waitForCalls(2) } + let first = Task { try await service.transcribe(wav: Data([1])) } + try await service.waitForCalls() + await service.complete(text: "first") + #expect(try await first.value == "first") + let second = Task { try await service.transcribe(wav: Data([2])) } + try await secondArrival.value + await service.complete(1, text: "second") + #expect(try await second.value == "second") + // An already-observed threshold must not require another arrival. + try await service.waitForCalls(2, timeout: .zero) + #expect(await service.clips == [Data([1]), Data([2])]) + } + + @Test func missingArrivalThrowsCountFailureAndCancelledWaitDoesNotRemainPending() async throws { + let service = DictationFixtureTranscriber() + await #expect(throws: DictationFixtureWaitFailure.transcriptionTimedOut(expected: 1, received: 0)) { + try await service.waitForCalls(timeout: .zero) + } + let cancelled = Task { try await service.waitForCalls() } + cancelled.cancel() + await #expect(throws: CancellationError.self) { try await cancelled.value } + let call = Task { try await service.transcribe(wav: Data([3])) } + try await service.waitForCalls() + await service.complete(text: "after cancellation") + #expect(try await call.value == "after cancellation") + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Dictation/DictationIssueExpiryTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Dictation/DictationIssueExpiryTests.swift new file mode 100644 index 000000000..769525a06 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Dictation/DictationIssueExpiryTests.swift @@ -0,0 +1,146 @@ +import Foundation +import Testing +@testable import MapleAppCore + +private actor DictationIssueExpiryClock { + let gate = ChatTestGate() + private(set) var delays: [Duration] = [] + + func sleep(_ duration: Duration) async throws { + // Recording's separate elapsed-time task is irrelevant to these tests. + guard duration == .seconds(5) else { throw CancellationError() } + delays.append(duration) + await gate.hold() + } +} + +@MainActor @Suite struct DictationIssueExpiryTests { + @Test func permissionFeedbackStaysUntilFiveSecondsAndLeavesTheDraftUnchanged() async throws { + let clock = DictationIssueExpiryClock() + let (runtime, coordinator, capture, _) = fixture(clock: clock) + runtime.draftText = "Keep this draft"; let draft = runtime.snapshot.draft + capture.permission = false + #expect(coordinator.start(runtime: runtime)) + try await dictationWait { coordinator.issue(for: runtime) == .permissionDenied } + let id = try #require(coordinator.issueID(for: runtime)) + let expiry = Task { await coordinator.expireIssueAfterDelay(id: id, runtime: runtime) } + await clock.gate.waitForArrival() + #expect(coordinator.issue(for: runtime) == .permissionDenied && coordinator.issueID(for: runtime) == id) + #expect(await clock.delays == [.seconds(5)]) + await clock.gate.release(); await expiry.value + #expect(coordinator.issue(for: runtime) == nil && coordinator.lastIssueID == nil) + #expect(runtime.snapshot.draft == draft && runtime.canSend && capture.starts == 0) + coordinator.invalidate(); runtime.invalidate() + } + + @Test func oldTimerCannotClearALaterOccurrenceOfTheSameIssue() async throws { + let clock = DictationIssueExpiryClock() + let (runtime, coordinator, capture, _) = fixture(clock: clock) + capture.permission = false + #expect(coordinator.start(runtime: runtime)) + try await dictationWait { coordinator.issue(for: runtime) == .permissionDenied } + let oldID = try #require(coordinator.issueID(for: runtime)) + let expiry = Task { await coordinator.expireIssueAfterDelay(id: oldID, runtime: runtime) } + await clock.gate.waitForArrival() + #expect(coordinator.start(runtime: runtime)) + try await dictationWait { + coordinator.issue(for: runtime) == .permissionDenied && coordinator.issueID(for: runtime) != oldID + } + let replacementID = try #require(coordinator.issueID(for: runtime)) + await clock.gate.release(); await expiry.value + #expect( + coordinator.issue(for: runtime) == .permissionDenied && coordinator.issueID(for: runtime) == replacementID) + coordinator.invalidate(); runtime.invalidate() + } + + @Test func cancellationOnDisappearancePreservesFeedbackEvenIfSleepReturnsNormally() async throws { + let clock = DictationIssueExpiryClock() + let (runtime, coordinator, capture, _) = fixture(clock: clock) + capture.permission = false + #expect(coordinator.start(runtime: runtime)) + try await dictationWait { coordinator.issue(for: runtime) == .permissionDenied } + let id = try #require(coordinator.issueID(for: runtime)) + let expiry = Task { await coordinator.expireIssueAfterDelay(id: id, runtime: runtime) } + await clock.gate.waitForArrival() + expiry.cancel(); await clock.gate.release(); await expiry.value + #expect(coordinator.issue(for: runtime) == .permissionDenied && coordinator.issueID(for: runtime) == id) + coordinator.invalidate(); runtime.invalidate() + } + + @Test func aClonedDraftCannotExpireAnotherRuntimeAndRetirementClearsTheOccurrence() async throws { + let clock = DictationIssueExpiryClock() + let (runtime, coordinator, capture, _) = fixture(clock: clock) + capture.permission = false + #expect(coordinator.start(runtime: runtime)) + try await dictationWait { coordinator.issue(for: runtime) == .permissionDenied } + let id = try #require(coordinator.issueID(for: runtime)) + let store = ChatFixtureStore() + let clone = ChatRuntime(scope: runtime.scope, service: ChatFixtureService(store: store), persistence: store) + #expect(clone.loadRetainedSnapshot(runtime.snapshot)) + await coordinator.expireIssueAfterDelay(id: id, runtime: clone) + #expect(await clock.delays.isEmpty) + #expect(coordinator.issue(for: clone) == nil && coordinator.issueID(for: runtime) == id) + let expiry = Task { await coordinator.expireIssueAfterDelay(id: id, runtime: runtime) } + await clock.gate.waitForArrival() + coordinator.invalidate(); runtime.invalidate() + await clock.gate.release(); await expiry.value + #expect(coordinator.lastIssue == nil && coordinator.lastIssueID == nil && coordinator.issue(for: clone) == nil) + clone.invalidate() + } + + @Test func changedComposerOwnerCannotExpireTheHeldDraftIssue() async throws { + let clock = DictationIssueExpiryClock() + let (runtime, coordinator, capture, _) = fixture(clock: clock) + runtime.draftText = "Queued draft"; #expect(runtime.enqueueDraft()) + let queued = try #require(runtime.queuedTurns.first) + runtime.draftText = "Held draft"; let owner = runtime.snapshot.draft.id + capture.permission = false + #expect(coordinator.start(runtime: runtime)) + try await dictationWait { coordinator.issue(for: runtime) == .permissionDenied } + let id = try #require(coordinator.issueID(for: runtime)) + let expiry = Task { await coordinator.expireIssueAfterDelay(id: id, runtime: runtime) } + await clock.gate.waitForArrival() + #expect(runtime.beginQueueEdit(id: queued.id)) + #expect(runtime.snapshot.draft.id != owner && coordinator.issue(for: runtime) == nil) + await clock.gate.release(); await expiry.value + #expect(coordinator.lastIssueID == id) + runtime.cancelQueueEdit() + #expect(runtime.snapshot.draft.id == owner && coordinator.issueID(for: runtime) == id) + #expect(runtime.draftText == "Held draft" && runtime.queuedTurns == [queued]) + coordinator.invalidate(); runtime.invalidate() + } + + @Test func expiryKeepsTheFailedClipAndRetryUsesItWithoutAnotherMicrophoneCapture() async throws { + let clock = DictationIssueExpiryClock() + let (runtime, coordinator, capture, service) = fixture(clock: clock) + runtime.draftText = "Keep this draft"; let owner = runtime.snapshot.draft.id + #expect(coordinator.start(runtime: runtime)); try await dictationWait { capture.starts == 1 } + coordinator.stop(runtime: runtime) + try await service.waitForCalls(); await service.complete(fails: true) + try await dictationWait { coordinator.status(for: runtime)?.phase == .failed } + let id = try #require(coordinator.issueID(for: runtime)) + let expiry = Task { await coordinator.expireIssueAfterDelay(id: id, runtime: runtime) } + await clock.gate.waitForArrival(); await clock.gate.release(); await expiry.value + #expect(coordinator.issue(for: runtime) == nil) + #expect(coordinator.status(for: runtime)?.phase == .failed) + #expect(coordinator.status(for: runtime)?.issue == .transcriptionUnavailable) + #expect(runtime.busyDraftIDs.contains(owner) && runtime.draftText == "Keep this draft") + coordinator.retry(runtime: runtime); try await service.waitForCalls(2) + let clips = await service.clips + #expect(clips.count == 2 && clips[0] == clips[1] && capture.starts == 1) + coordinator.cancel(runtime: runtime); await service.complete(1) + #expect(runtime.canSend && runtime.draftText == "Keep this draft") + coordinator.invalidate(); runtime.invalidate() + } + + private func fixture(clock: DictationIssueExpiryClock) -> ( + ChatRuntime, DictationCoordinator, DictationFixtureCapture, DictationFixtureTranscriber + ) { + let (runtime, _, _) = ChatRuntimeTests().fixture() + let capture = DictationFixtureCapture(), transcriber = DictationFixtureTranscriber() + let coordinator = DictationCoordinator( + scope: runtime.scope, capture: capture, transcriber: transcriber, + hooks: ChatRuntimeHooks(sleep: { try await clock.sleep($0) })) + return (runtime, coordinator, capture, transcriber) + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Dictation/DictationQueueOwnershipTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Dictation/DictationQueueOwnershipTests.swift new file mode 100644 index 000000000..25897f5a3 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Dictation/DictationQueueOwnershipTests.swift @@ -0,0 +1,82 @@ +import Foundation +import Testing +@testable import MapleAppCore + +@MainActor @Suite struct DictationQueueOwnershipTests { + @Test func beginEditingStopsCaptureSynchronouslyAndHeldBResultNeverChangesA() async throws { + let (runtime, dictation, capture, service, _) = dictationFixture() + runtime.draftText = "Queued A"; #expect(runtime.enqueueDraft()) + let queued = try #require(runtime.queuedTurns.first) + runtime.draftText = "Held B"; let ownerB = runtime.snapshot.draft.id + #expect(dictation.start(runtime: runtime)); try await dictationWait { capture.starts == 1 } + #expect(runtime.beginQueueEdit(id: queued.id)) + #expect(capture.active == nil && capture.stops == 1) + #expect(runtime.snapshot.draft.text == "Queued A" && !runtime.busyDraftIDs.contains(runtime.snapshot.draft.id)) + try await service.waitForCalls(); await service.complete(text: "voice B") + try await dictationWait { dictation.statuses.isEmpty } + #expect(runtime.snapshot.draft.text == "Queued A") + #expect(runtime.snapshot.queue?.edit?.heldDraft.text == "Held B voice B") + #expect(runtime.snapshot.queue?.edit?.heldDraft.id == ownerB) + #expect(runtime.queuedTurns == [queued] && runtime.isEditingQueue) + #expect(runtime.saveQueueEdit()); #expect(runtime.draftText == "Held B voice B") + dictation.invalidate(); runtime.invalidate() + } + + @Test func heldBProcessingDoesNotBlockSavingAAndBInsertionFindsRestoredOwner() async throws { + let (runtime, dictation, capture, service, _) = dictationFixture() + runtime.draftText = "Queued A"; #expect(runtime.enqueueDraft()) + let queued = try #require(runtime.queuedTurns.first) + runtime.draftText = "Held B"; let ownerB = runtime.snapshot.draft.id + #expect(dictation.start(runtime: runtime)); try await dictationWait { capture.starts == 1 } + #expect(runtime.beginQueueEdit(id: queued.id)); try await service.waitForCalls() + runtime.draftText = "Edited A" + #expect(runtime.saveQueueEdit()) + #expect(runtime.queuedTurns.first?.draft.text == "Edited A" && runtime.snapshot.draft.id == ownerB) + #expect(!runtime.canSend) + await service.complete(text: "voice B"); try await dictationWait { dictation.statuses.isEmpty } + #expect(runtime.draftText == "Held B voice B" && runtime.canSend) + #expect(runtime.queuedTurns.first?.draft.text == "Edited A" && runtime.isQueuePaused) + dictation.invalidate(); runtime.invalidate() + } + + @Test(arguments: [false, true]) + func cancelOrDeleteARejectsItsLateResultAndKeepsIndependentB(delete: Bool) async throws { + let (runtime, dictation, capture, service, _) = dictationFixture() + runtime.draftText = "Queued A"; #expect(runtime.enqueueDraft()) + let queued = try #require(runtime.queuedTurns.first) + runtime.draftText = "Held B" + #expect(dictation.start(runtime: runtime)); try await dictationWait { capture.starts == 1 } + #expect(runtime.beginQueueEdit(id: queued.id)); try await service.waitForCalls() + let ownerA = runtime.snapshot.draft.id + #expect(dictation.start(runtime: runtime)); try await dictationWait { capture.starts == 2 } + dictation.stop(runtime: runtime); try await service.waitForCalls(2) + #expect(!runtime.saveQueueEdit()) + if delete { runtime.deleteQueuedTurn(id: queued.id) } else { runtime.cancelQueueEdit() } + #expect(!runtime.busyDraftIDs.contains(ownerA) && !runtime.canSend) + await service.complete(1, text: "Late A"); await service.complete(0, text: "voice B") + try await dictationWait { dictation.statuses.isEmpty } + #expect(runtime.draftText == "Held B voice B") + #expect(runtime.queuedTurns == (delete ? [] : [queued])) + dictation.invalidate(); runtime.invalidate() + } + + @Test func completedAEnablesSaveWithoutApplyingItAutomaticallyAndRestartHasNoJob() async throws { + let (runtime, dictation, capture, service, store) = dictationFixture() + runtime.draftText = "Queued A"; #expect(runtime.enqueueDraft()) + let queued = try #require(runtime.queuedTurns.first) + runtime.draftText = "Held B"; #expect(runtime.beginQueueEdit(id: queued.id)) + #expect(dictation.start(runtime: runtime)); try await dictationWait { capture.starts == 1 } + dictation.stopCaptureForNavigation(); try await service.waitForCalls() + await service.complete(text: "voice A"); try await dictationWait { dictation.statuses.isEmpty } + #expect(runtime.isEditingQueue && runtime.queuedTurns == [queued]) + #expect(runtime.draftText == "Queued A voice A") + await runtime.saveDraft() + let snapshot = try #require(await store.recorded("conversation")) + #expect(snapshot.queue?.edit?.heldDraft.text == "Held B") + let fresh = DictationCoordinator(scope: runtime.scope, capture: DictationFixtureCapture(), transcriber: service) + #expect(fresh.statuses.isEmpty) + #expect(runtime.saveQueueEdit()) + #expect(runtime.queuedTurns.first?.draft.text == "Queued A voice A" && runtime.draftText == "Held B") + fresh.invalidate(); dictation.invalidate(); runtime.invalidate() + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Dictation/DictationWorkflowTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Dictation/DictationWorkflowTests.swift new file mode 100644 index 000000000..a8b8e8869 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Dictation/DictationWorkflowTests.swift @@ -0,0 +1,339 @@ +import Foundation +import Testing +@testable import MapleAppCore + +@Suite struct DictationAnchorTests { + @Test func editsBeforeAtAndAcrossMarkerRespectGraphemeBoundaries() { + var anchor = DictationInsertionAnchor(text: "A👨‍👩‍👧‍👦B café", offsetUTF16: 12) + anchor.rebase(to: "New A👨‍👩‍👧‍👦B café") + #expect(anchor.inserting("voice") == "New A👨‍👩‍👧‍👦 voice B café") + anchor.rebase(to: "New A👨‍👩‍👧‍👦typedB café") + #expect(anchor.inserting("voice") == "New A👨‍👩‍👧‍👦typed voice B café") + anchor.rebase(to: "New replacement café") + #expect(anchor.inserting("voice") == "New replacement voice café") + } + + @Test func invalidUTF16OffsetNeverSplitsSurrogateOrCombiningSequence() { + #expect(DictationInsertionAnchor(text: "👩🏽‍💻x", offsetUTF16: 1).inserting("hello") == "hello 👩🏽‍💻x") + #expect(DictationInsertionAnchor(text: "e\u{301}x", offsetUTF16: 1).inserting("hello") == "hello e\u{301}x") + #expect(DictationInsertionAnchor(text: "Hi, there!", offsetUTF16: 2).inserting("friend") == "Hi friend, there!") + } + + @Test func exactTransactionsDistinguishDeletingIdenticalCharacters() { + var first = DictationInsertionAnchor(text: "aaaa", offsetUTF16: 2) + var last = first + let rebasedFirst = first.rebase( + using: DictationTextEdit(beforeText: "aaaa", rangeUTF16: NSRange(location: 0, length: 1), replacement: "")) + let rebasedLast = last.rebase( + using: DictationTextEdit(beforeText: "aaaa", rangeUTF16: NSRange(location: 3, length: 1), replacement: "")) + #expect(rebasedFirst && rebasedLast) + #expect(first.text == last.text && first.offsetUTF16 == 1 && last.offsetUTF16 == 2) + } + + @Test func wavIsBoundedMonoPCM16AndContainsNoExtraEnvelope() throws { + let pcm = Data([1, 0, 255, 127]) + let wav = try DictationAudio.wav(pcm16Mono: pcm) + #expect(wav.count == 48 && String(decoding: wav.prefix(4), as: UTF8.self) == "RIFF") + #expect(Array(wav[22..<24]) == [1, 0]) + #expect(Array(wav[24..<28]) == [128, 62, 0, 0]) + #expect(Array(wav[34..<36]) == [16, 0] && wav.suffix(4) == pcm) + #expect(throws: DictationIssue.emptyRecording) { try DictationAudio.wav(pcm16Mono: Data()) } + #expect(throws: DictationIssue.recordingTooLarge) { try DictationAudio.wav(pcm16Mono: Data([1])) } + #expect(throws: DictationIssue.recordingTooLarge) { + try DictationAudio.wav(pcm16Mono: Data(repeating: 0, count: DictationAudio.maximumPCMBytes + 2)) + } + } +} + +@MainActor @Suite struct DictationWorkflowTests { + @Test(arguments: ["", " \n\t"]) + func emptyTranscriptionShowsNoSpeechIssueAndReleasesDraftWithoutChangingIt(_ text: String) async throws { + let (runtime, dictation, capture, service, _) = dictationFixture() + runtime.draftText = "Keep this draft"; let original = runtime.snapshot.draft + #expect(dictation.start(runtime: runtime)); try await dictationWait { capture.starts == 1 } + dictation.stop(runtime: runtime); try await service.waitForCalls(); await service.complete(text: text) + try await dictationWait { dictation.statuses.isEmpty } + #expect(dictation.issue(for: runtime) == .emptyTranscription) + #expect(runtime.snapshot.draft == original && runtime.canSend && !runtime.hasUnresolvedRun) + dictation.invalidate(); runtime.invalidate() + } + + @Test func pendingTextUsesStopMarkerDespiteLaterCaretAndEditsAndNeverSends() async throws { + let (runtime, dictation, capture, service, _) = dictationFixture() + runtime.draftText = "Hello world"; dictationEditor(runtime, dictation, offset: 5) + #expect(dictation.start(runtime: runtime)); #expect(!runtime.canSend) + try await dictationWait { capture.starts == 1 } + runtime.draftText = "Hello great world"; dictationEditor(runtime, dictation, offset: 11) + dictation.stop(runtime: runtime); #expect(capture.stops == 1) + try await service.waitForCalls() + runtime.draftText = "Dear Hello great world"; dictationEditor(runtime, dictation, offset: 0) + await service.complete(text: "voice") + try await dictationWait { dictation.statuses.isEmpty } + #expect(runtime.draftText == "Dear Hello great voice world") + #expect(runtime.canSend && !runtime.hasUnresolvedRun) + dictation.invalidate(); runtime.invalidate() + } + + @Test func completedResultWaitsForIMECommitAndRebasesCommittedSelectionReplacement() async throws { + let (runtime, dictation, capture, service, _) = dictationFixture() + runtime.draftText = "Before after"; dictationEditor(runtime, dictation, offset: 6) + #expect(dictation.start(runtime: runtime)); try await dictationWait { capture.starts == 1 } + dictation.stop(runtime: runtime); try await service.waitForCalls() + runtime.draftText = "前 after" + dictationEditor(runtime, dictation, offset: 1, marked: NSRange(location: 0, length: 1)) + await service.complete(text: "voice") + try await dictationWait { dictation.status(for: runtime)?.phase == .waitingForComposition } + #expect(runtime.draftText == "前 after" && !runtime.canSend) + runtime.draftText = "前文 after"; dictationEditor(runtime, dictation, offset: 2) + #expect(runtime.draftText == "前文 voice after" && runtime.canSend) + dictation.invalidate(); runtime.invalidate() + } + + @Test func cancellationRejectsLateResultWithoutChangingTextImagesOrAnotherBusyOwner() async throws { + let (runtime, dictation, capture, service, _) = dictationFixture() + runtime.updateDraft( + ChatDraft( + id: runtime.snapshot.draft.id, text: "Keep", + images: [ChatImageAttachment(data: Data([1]), contentType: "image/png")])) + let before = runtime.snapshot.draft; let unrelated = UUID(); runtime.busyDraftIDs.insert(unrelated) + #expect(dictation.start(runtime: runtime)); try await dictationWait { capture.starts == 1 } + dictation.stop(runtime: runtime); try await service.waitForCalls() + dictation.cancel(runtime: runtime); await service.complete() + for _ in 0..<20 { await Task.yield() } + #expect(runtime.snapshot.draft == before && runtime.busyDraftIDs == [unrelated]) + dictation.invalidate(); runtime.invalidate() + } + + @Test func permissionDenialAndNavigationDuringPromptNeverStartCapture() async throws { + let (runtime, dictation, capture, _, _) = dictationFixture() + let gate = ChatTestGate(); capture.permissionGate = gate; runtime.draftText = "Keep" + #expect(dictation.start(runtime: runtime)); await gate.waitForArrival() + dictation.stopCaptureForNavigation(); await gate.release() + try await dictationWait { dictation.statuses.isEmpty } + #expect(capture.starts == 0 && runtime.canSend) + capture.permissionGate = nil; capture.permission = false + #expect(dictation.start(runtime: runtime)); try await dictationWait { dictation.statuses.isEmpty } + #expect(dictation.lastIssue == .permissionDenied && capture.starts == 0 && runtime.draftText == "Keep") + dictation.invalidate(); runtime.invalidate() + } + + @Test func retryUsesSameMemoryClipAndDiscardReleasesOwner() async throws { + let (runtime, dictation, capture, service, _) = dictationFixture() + runtime.draftText = "Keep" + #expect(dictation.start(runtime: runtime)); try await dictationWait { capture.starts == 1 } + dictation.stop(runtime: runtime); try await service.waitForCalls(); await service.complete(fails: true) + try await dictationWait { dictation.status(for: runtime)?.phase == .failed } + #expect(runtime.draftText == "Keep" && !runtime.canSend) + dictation.retry(runtime: runtime); try await service.waitForCalls(2) + let clips = await service.clips; try #require(clips.count == 2) + #expect(clips[0] == clips[1] && capture.starts == 1) + dictation.cancel(runtime: runtime); await service.complete(1) + #expect(runtime.canSend && runtime.draftText == "Keep") + dictation.invalidate(); runtime.invalidate() + } + + @Test func failedMicrophoneStartHasDraftScopedRecoveryAndReleasesItsBusyOwner() async throws { + let (runtime, dictation, capture, service, _) = dictationFixture() + runtime.draftText = "Keep this draft" + let draft = runtime.snapshot.draft + let unrelated = UUID(); runtime.busyDraftIDs.insert(unrelated) + capture.failStart = true + #expect(dictation.start(runtime: runtime)) + try await dictationWait { dictation.statuses.isEmpty } + #expect(dictation.issue(for: runtime) == .captureUnavailable) + #expect(runtime.snapshot.draft == draft && runtime.busyDraftIDs == [unrelated]) + #expect(capture.active == nil) + #expect(await service.clips.isEmpty) + dictation.dismissIssue(); capture.failStart = false + #expect(dictation.start(runtime: runtime)) + try await dictationWait { capture.starts == 1 } + #expect(dictation.issue(for: runtime) == nil && capture.active != nil) + dictation.cancel(runtime: runtime) + #expect(runtime.snapshot.draft == draft && runtime.busyDraftIDs == [unrelated]) + dictation.invalidate(); runtime.invalidate() + } + + @Test func limitStopsOnceAndLogoutRejectsPendingResultAndSameUserRelogin() async throws { + let (runtime, dictation, capture, service, _) = dictationFixture() + runtime.draftText = "Old account draft" + #expect(dictation.start(runtime: runtime)); try await dictationWait { capture.starts == 1 } + capture.limits?(); capture.limits?(); #expect(capture.stops == 1) + try await service.waitForCalls() + dictation.invalidate(); runtime.invalidate() + let (replacement, _, _) = ChatRuntimeTests().fixture() + replacement.draftText = "New account generation" + #expect(!dictation.start(runtime: replacement)) + await service.complete() + for _ in 0..<20 { await Task.yield() } + #expect(runtime.draftText.isEmpty && replacement.draftText == "New account generation") + replacement.invalidate() + } + + @Test func protectedSaveFailureKeepsInsertedResultInMemoryWithoutSavingAudio() async throws { + let (runtime, dictation, capture, service, store) = dictationFixture() + runtime.draftText = "Keep"; await runtime.saveDraft() + await store.configure(fail: true) + #expect(dictation.start(runtime: runtime)); try await dictationWait { capture.starts == 1 } + dictation.stop(runtime: runtime); try await service.waitForCalls(); await service.complete(text: "voice") + try await dictationWait { dictation.statuses.isEmpty } + await runtime.saveDraft() + #expect(runtime.draftText == "Keep voice" && runtime.lastIssue == .storageUnavailable) + #expect(await store.recorded("conversation")?.draft.text == "Keep") + await store.configure(); await runtime.saveDraft() + #expect(await store.recorded("conversation")?.draft.text == "Keep voice") + dictation.invalidate(); runtime.invalidate() + } + + @Test func staleEditorSnapshotsCannotMoveStopMarker() async throws { + let (runtime, dictation, capture, service, _) = dictationFixture() + runtime.draftText = "Before after"; dictationEditor(runtime, dictation, offset: 6) + #expect(dictation.start(runtime: runtime)); try await dictationWait { capture.starts == 1 } + dictation.stop(runtime: runtime); try await service.waitForCalls() + dictation.updateEditor( + runtime: runtime, draftID: runtime.snapshot.draft.id, + state: DictationEditorState(text: "Outdated", selectionUTF16: NSRange(location: 0, length: 0))) + await service.complete(text: "voice"); try await dictationWait { dictation.statuses.isEmpty } + #expect(runtime.draftText == "Before voice after") + dictation.invalidate(); runtime.invalidate() + } + + @Test func retiredRuntimeCannotStartMicrophoneAfterPermissionFinishes() async throws { + let (runtime, dictation, capture, _, _) = dictationFixture() + let gate = ChatTestGate(); capture.permissionGate = gate + runtime.draftText = "Retired" + #expect(dictation.start(runtime: runtime)); await gate.waitForArrival() + runtime.invalidate(); await gate.release() + try await dictationWait { dictation.statuses.isEmpty } + #expect(capture.starts == 0 && !dictation.start(runtime: runtime)) + dictation.invalidate() + } + + @Test func runtimeRetirementSynchronouslyStopsCaptureAndMatchedDeletionCancelsOnlyItsOwner() async throws { + let (runtime, dictation, capture, service, _) = dictationFixture() + runtime.draftText = "Local draft" + #expect(dictation.start(runtime: runtime)); try await dictationWait { capture.starts == 1 } + let fence = UUID(); runtime.beginDeletionFence(fence) + #expect(capture.active != nil) + runtime.onQuiesceForDeletion = { + for owner in runtime.snapshot.ownedDrafts { dictation.cancel(runtime: runtime, draftID: owner.id) } + } + runtime.requestDeletionQuiescence(fence) + #expect(capture.active == nil && dictation.statuses.isEmpty) + #expect(await service.clips.isEmpty) + runtime.releaseDeletionFence(fence) + #expect(dictation.start(runtime: runtime)); try await dictationWait { capture.starts == 2 } + runtime.invalidate() + #expect(capture.active == nil && dictation.statuses.isEmpty) + dictation.invalidate() + } + + @Test func exactCoalescedEditsCommitCompositionBeforeResultInsertion() async throws { + let (runtime, dictation, capture, service, _) = dictationFixture() + runtime.draftText = "aaaa"; dictationEditor(runtime, dictation, offset: 2) + #expect(dictation.start(runtime: runtime)); try await dictationWait { capture.starts == 1 } + dictation.stop(runtime: runtime); try await service.waitForCalls() + runtime.draftText = "aaa" + dictation.updateEditor( + runtime: runtime, draftID: runtime.snapshot.draft.id, + state: DictationEditorState( + text: "aaa", selectionUTF16: NSRange(location: 0, length: 0), + markedRangeUTF16: NSRange(location: 0, length: 1)), + edits: [DictationTextEdit(beforeText: "aaaa", rangeUTF16: NSRange(location: 0, length: 1), replacement: "")] + ) + await service.complete(text: "voice") + try await dictationWait { dictation.status(for: runtime)?.phase == .waitingForComposition } + runtime.draftText = "前aaa" + dictation.updateEditor( + runtime: runtime, draftID: runtime.snapshot.draft.id, + state: DictationEditorState(text: "前aaa", selectionUTF16: NSRange(location: 1, length: 0)), + edits: [ + DictationTextEdit(beforeText: "aaa", rangeUTF16: NSRange(location: 0, length: 0), replacement: "まえ"), + DictationTextEdit(beforeText: "まえaaa", rangeUTF16: NSRange(location: 0, length: 2), replacement: "前"), + ]) + #expect(runtime.draftText == "前a voice aa" && dictation.statuses.isEmpty) + dictation.invalidate(); runtime.invalidate() + } + + @Test func unmatchedInteractiveTransactionRequiresExplicitPositionInsteadOfGuessing() async throws { + let (runtime, dictation, capture, service, _) = dictationFixture() + runtime.draftText = "aaaa"; dictationEditor(runtime, dictation, offset: 2) + #expect(dictation.start(runtime: runtime)); try await dictationWait { capture.starts == 1 } + dictation.stop(runtime: runtime); try await service.waitForCalls() + runtime.draftText = "aaa" + dictation.updateEditor( + runtime: runtime, draftID: runtime.snapshot.draft.id, + state: DictationEditorState(text: "aaa", selectionUTF16: NSRange(location: 0, length: 0)), + edits: [ + DictationTextEdit( + beforeText: "aaaa", rangeUTF16: NSRange(location: NSNotFound, length: 0), replacement: "") + ]) + await service.complete(text: "voice") + try await dictationWait { dictation.status(for: runtime)?.phase == .waitingForInsertionPoint } + #expect(runtime.draftText == "aaa" && !runtime.canSend) + dictation.insertAtCurrentPosition(runtime: runtime) + #expect(runtime.draftText == "voice aaa" && runtime.canSend) + dictation.invalidate(); runtime.invalidate() + } + + @Test func retainedClipsAreBoundedAndForeignAccountCannotUseTheCapture() async throws { + let (first, dictation, capture, service, store) = dictationFixture() + var runtimes = [first] + for index in 1...4 { + runtimes.append( + ChatRuntime( + scope: first.scope, service: ChatFixtureService(store: store), persistence: store, + conversationID: "conversation-\(index)")) + } + for index in 0..<4 { + runtimes[index].draftText = "Preserve \(index)" + #expect(dictation.start(runtime: runtimes[index])) + try await dictationWait { capture.starts == index + 1 } + dictation.stop(runtime: runtimes[index]); try await service.waitForCalls(index + 1) + } + runtimes[4].draftText = "Keep fifth draft" + #expect(!dictation.start(runtime: runtimes[4])) + #expect(dictation.issue(for: runtimes[4]) == .tooManyOperations) + #expect(dictation.issue(for: first) == nil && runtimes[4].canSend) + let (foreign, _, _) = ChatRuntimeTests().fixture() + foreign.draftText = "Other account generation" + #expect(!dictation.start(runtime: foreign)) + for index in 0..<4 { await service.complete(index, fails: true) } + try await dictationWait { dictation.statuses.values.allSatisfy { $0.phase == .failed } } + dictation.invalidate() + #expect(runtimes.allSatisfy { $0.busyDraftIDs.isEmpty } && capture.active == nil) + for runtime in runtimes { runtime.invalidate() }; foreign.invalidate() + } + + @Test func insertionPreservesTheCaretMovedAfterStopRelativeToItsSurroundingText() async throws { + let (runtime, dictation, capture, service, _) = dictationFixture() + runtime.draftText = "Hello world"; let owner = runtime.snapshot.draft.id + dictationEditor(runtime, dictation, offset: 5) + #expect(dictation.start(runtime: runtime)); try await dictationWait { capture.starts == 1 } + dictation.stop(runtime: runtime); try await service.waitForCalls() + dictationEditor(runtime, dictation, offset: 11) + await service.complete(text: "voice"); try await dictationWait { dictation.statuses.isEmpty } + let editor = dictation.editorState(runtime: runtime, draftID: owner) + #expect(runtime.draftText == "Hello voice world") + #expect(editor?.text == runtime.draftText && editor?.selectionUTF16 == NSRange(location: 17, length: 0)) + dictation.invalidate(); runtime.invalidate() + } + + @Test func anotherRuntimeWithClonedDraftCannotRedirectTheEditorOrItsMarker() async throws { + let (runtime, dictation, capture, service, store) = dictationFixture() + runtime.draftText = "Hello world"; let owner = runtime.snapshot.draft.id + dictationEditor(runtime, dictation, offset: 5) + #expect(dictation.start(runtime: runtime)); try await dictationWait { capture.starts == 1 } + dictation.stop(runtime: runtime); try await service.waitForCalls() + let clone = ChatRuntime(scope: runtime.scope, service: ChatFixtureService(store: store), persistence: store) + #expect(clone.loadRetainedSnapshot(runtime.snapshot)) + dictation.updateEditor( + runtime: clone, draftID: owner, + state: DictationEditorState( + text: runtime.draftText, selectionUTF16: NSRange(location: 0, length: 0), + markedRangeUTF16: NSRange(location: 0, length: 1))) + #expect(dictation.editorState(runtime: clone, draftID: owner) == nil) + await service.complete(text: "voice"); try await dictationWait { dictation.statuses.isEmpty } + #expect(runtime.draftText == "Hello voice world" && clone.draftText == "Hello world") + dictation.invalidate(); runtime.invalidate(); clone.invalidate() + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Images/ImageImportTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Images/ImageImportTests.swift new file mode 100644 index 000000000..9131bcc7d --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Images/ImageImportTests.swift @@ -0,0 +1,327 @@ +import CoreGraphics +import Foundation +import ImageIO +import Testing +import UniformTypeIdentifiers +@testable import MapleAppCore + +private actor ImageFixtureProcessor: ImageProcessing { + func process(_ data: Data) throws -> ChatImageAttachment { + if data.first == 0 { throw ImageImportIssue.invalidImage } + return ChatImageAttachment(data: data, contentType: "image/png") + } +} + +@MainActor private final class ImageAdmissionState { var allowed = true } + +private actor ImageFeedbackClock { + let gates: [ChatTestGate] + private(set) var delays: [Duration] = [] + init(gates: [ChatTestGate]) { self.gates = gates } + func sleep(_ duration: Duration) async { + let index = delays.count + delays.append(duration) + if index < gates.count { await gates[index].hold() } + } +} + +@MainActor struct ImageImportOwnershipTests { + private func makeRuntime(snapshot: ChatSnapshot = ChatSnapshot()) async -> ChatRuntime { + let store = ChatFixtureStore(); await store.seed(snapshot) + let runtime = ChatRuntime( + scope: AccountScope(apiOrigin: "http://localhost:1234", principalID: "image-fixture"), + service: ChatFixtureService(store: store), persistence: store) + await runtime.load(chatID: nil) + return runtime + } + + @Test func attachmentFeedbackExpiresAfterFiveSecondsWithoutChangingTheDraft() async { + let runtime = await makeRuntime(); runtime.draftText = "Keep this draft" + let gate = ChatTestGate(), clock = ImageFeedbackClock(gates: [gate]) + let importer = ImageImportController( + runtime: runtime, processor: ImageFixtureProcessor(), sleep: { await clock.sleep($0) }) + importer.report(.invalidImage) + let expiry = importer.issueExpiry + await gate.waitForArrival() + #expect(importer.issue == .invalidImage) + #expect(await clock.delays == [.seconds(5)]) + await gate.release(); await expiry?.value + #expect(importer.issue == nil) + #expect(runtime.snapshot.draft.text == "Keep this draft") + #expect(runtime.snapshot.draft.images.isEmpty) + importer.invalidate(); runtime.invalidate() + } + + @Test func anOlderTimeoutCannotClearANewOccurrenceOfTheSameAttachmentError() async { + let runtime = await makeRuntime(), first = ChatTestGate(), second = ChatTestGate() + let clock = ImageFeedbackClock(gates: [first, second]) + let importer = ImageImportController( + runtime: runtime, processor: ImageFixtureProcessor(), sleep: { await clock.sleep($0) }) + importer.report(.tooLarge) + let oldExpiry = importer.issueExpiry + await first.waitForArrival() + importer.report(.tooLarge) + let newExpiry = importer.issueExpiry + await second.waitForArrival() + await first.release(); await oldExpiry?.value + #expect(importer.issue == .tooLarge) + await second.release(); await newExpiry?.value + #expect(importer.issue == nil) + #expect(await clock.delays == [.seconds(5), .seconds(5)]) + importer.invalidate(); runtime.invalidate() + } + + @Test func retiringAnImporterCancelsItsPendingFeedbackExpiry() async { + let runtime = await makeRuntime(), gate = ChatTestGate() + let clock = ImageFeedbackClock(gates: [gate]) + let importer = ImageImportController( + runtime: runtime, processor: ImageFixtureProcessor(), sleep: { await clock.sleep($0) }) + importer.report(.unavailable) + let expiry = importer.issueExpiry + await gate.waitForArrival() + importer.invalidate() + #expect(expiry?.isCancelled == true) + await gate.release(); await expiry?.value + #expect(importer.issue == nil) + #expect(importer.issueExpiry == nil) + runtime.invalidate() + } + + @Test func unavailableAccessDoesNotReadAProvider() async { + let runtime = await makeRuntime(); runtime.draftText = "Keep this draft" + let importer = ImageImportController(runtime: runtime, processor: ImageFixtureProcessor(), admission: { false }) + importer.importImages( + [ + SelectedImageSource { + Issue.record("An unavailable account read an image provider"); return Data([1]) + } + ], draftID: runtime.snapshot.draft.id) + await importer.waitForImports() + #expect(importer.issue == .unavailable) + #expect(runtime.snapshot.draft.text == "Keep this draft") + #expect(runtime.snapshot.draft.images.isEmpty) + runtime.invalidate() + } + + @Test func revokedAccessDuringProviderReadPreservesTheEntireBatch() async { + let runtime = await makeRuntime(); runtime.draftText = "Keep this draft" + let state = ImageAdmissionState(), gate = ChatTestGate() + let importer = ImageImportController( + runtime: runtime, processor: ImageFixtureProcessor(), admission: { state.allowed }) + importer.importImages( + [ + SelectedImageSource { Data([1]) }, + SelectedImageSource { + await gate.hold(); return Data([2]) + }, + ], draftID: runtime.snapshot.draft.id) + await gate.waitForArrival(); state.allowed = false; await gate.release() + await importer.waitForImports() + #expect(importer.issue == .unavailable) + #expect(runtime.snapshot.draft.text == "Keep this draft") + #expect(runtime.snapshot.draft.images.isEmpty) + #expect(runtime.busyDraftIDs.isEmpty) + runtime.invalidate() + } + + @Test func quiescencePreventsAReplacementImportFromStarting() async { + let runtime = await makeRuntime() + let importer = ImageImportController(runtime: runtime, processor: ImageFixtureProcessor()) + let fence = UUID(); runtime.beginDeletionFence(fence); runtime.requestDeletionQuiescence(fence) + importer.importImages( + [ + SelectedImageSource { + Issue.record("A fenced importer read its provider"); return Data([1]) + } + ], + draftID: runtime.snapshot.draft.id) + await importer.waitForImports() + #expect(importer.issue == .retired) + #expect(runtime.snapshot.draft.images.isEmpty) + runtime.invalidate() + } + + @Test func temporaryBytesAreBoundedAcrossSimultaneousDrafts() async { + let budget = ImageImportBudget(maximumTemporaryBytes: 10) + let first = await makeRuntime(), second = await makeRuntime() + let firstImporter = ImageImportController(runtime: first, processor: ImageFixtureProcessor(), budget: budget) + let secondImporter = ImageImportController(runtime: second, processor: ImageFixtureProcessor(), budget: budget) + let gate = ChatTestGate() + firstImporter.importImages( + [ + SelectedImageSource { Data(repeating: 1, count: 6) }, + SelectedImageSource { + await gate.hold(); return Data([2, 2]) + }, + ], draftID: first.snapshot.draft.id) + await gate.waitForArrival() + secondImporter.importImages( + [SelectedImageSource { Data(repeating: 2, count: 6) }], draftID: second.snapshot.draft.id) + await secondImporter.waitForImports() + #expect(secondImporter.issue == .attachmentLimit) + #expect(second.snapshot.draft.images.isEmpty) + await gate.release(); await firstImporter.waitForImports() + #expect(first.snapshot.draft.images.count == 2) + first.invalidate(); second.invalidate() + } + + @Test func aFailedMultiSelectionLeavesTheEntireDraftUnchanged() async { + let original = ChatDraft( + text: "Keep this text", images: [ChatImageAttachment(data: Data([8]), contentType: "image/png")]) + let runtime = await makeRuntime(snapshot: ChatSnapshot(draft: original)) + let importer = ImageImportController(runtime: runtime, processor: ImageFixtureProcessor()) + importer.importImages( + [SelectedImageSource { Data([1]) }, SelectedImageSource { Data([0]) }], draftID: original.id) + await importer.waitForImports() + #expect(importer.issue == .invalidImage) + #expect(runtime.snapshot.draft == original) + #expect(runtime.busyDraftIDs.isEmpty) + runtime.invalidate() + } + + @Test func importToHeldDraftDoesNotOverwriteQueueEditorOrLaterTyping() async { + let original = ChatDraft(text: "B") + let queued = ChatQueuedTurn(draft: ChatDraft(text: "A")) + let runtime = await makeRuntime( + snapshot: ChatSnapshot(draft: original, queue: ChatQueueState(items: [queued], paused: true))) + let importer = ImageImportController(runtime: runtime, processor: ImageFixtureProcessor()) + let gate = ChatTestGate() + importer.importImages( + [ + SelectedImageSource { + await gate.hold(); return Data([1]) + } + ], draftID: original.id) + await gate.waitForArrival() + #expect(runtime.beginQueueEdit(id: queued.id)) + runtime.draftText = "A edited while B imports" + await gate.release(); await importer.waitForImports() + #expect(runtime.snapshot.draft.text == "A edited while B imports") + #expect(runtime.snapshot.draft.images.isEmpty) + #expect(runtime.snapshot.queue?.edit?.heldDraft.text == "B") + #expect(runtime.snapshot.queue?.edit?.heldDraft.images.count == 1) + runtime.cancelQueueEdit() + #expect(runtime.snapshot.draft.id == original.id) + #expect(runtime.snapshot.draft.images.count == 1) + runtime.invalidate() + } + + @Test func retiredImportCannotWriteIntoReplacementAccount() async { + let runtime = await makeRuntime(); runtime.draftText = "Old account" + let replacement = await makeRuntime(); replacement.draftText = "Replacement" + let importer = ImageImportController(runtime: runtime, processor: ImageFixtureProcessor()) + let gate = ChatTestGate() + importer.importImages( + [ + SelectedImageSource { + await gate.hold(); return Data([1]) + } + ], draftID: runtime.snapshot.draft.id) + await gate.waitForArrival() + importer.invalidate(); runtime.invalidate() + await gate.release(); await importer.waitForImports() + #expect(replacement.snapshot.draft.text == "Replacement") + #expect(replacement.snapshot.draft.images.isEmpty) + #expect(runtime.snapshot.draft.images.isEmpty) + #expect(runtime.busyDraftIDs.isEmpty) + replacement.invalidate() + } + + @Test func capacityFailureDoesNotReplaceExistingImages() async { + let runtime = await makeRuntime(); runtime.draftText = "Keep" + runtime.canAdmitSnapshot = { _ in false } + let importer = ImageImportController(runtime: runtime, processor: ImageFixtureProcessor()) + importer.importImages([SelectedImageSource { Data([1]) }], draftID: runtime.snapshot.draft.id) + await importer.waitForImports() + #expect(importer.issue == .attachmentLimit) + #expect(runtime.snapshot.draft.text == "Keep") + #expect(runtime.snapshot.draft.images.isEmpty) + runtime.invalidate() + } + + @Test(arguments: [ImageImportIssue.accountImageLimit, .accountAttachmentLimit]) + func accountRetentionRejectionsUseTheirOwnFeedbackAndPreserveTheDraft(_ issue: ImageImportIssue) async { + let original = ChatDraft( + text: "Keep this text", images: [ChatImageAttachment(data: Data([8]), contentType: "image/png")]) + let runtime = await makeRuntime(snapshot: ChatSnapshot(draft: original)) + let importer = ImageImportController( + runtime: runtime, processor: ImageFixtureProcessor(), capacityIssue: { _ in issue }) + importer.importImages([SelectedImageSource { Data([1]) }], draftID: original.id) + await importer.waitForImports() + #expect(importer.issue == issue) + #expect(runtime.snapshot.draft == original) + #expect(runtime.busyDraftIDs.isEmpty) + importer.invalidate(); runtime.invalidate() + } +} + +struct ImageCodecTests { + @Test func orientationIsAppliedAndSourceMetadataIsRemoved() async throws { + let input = try fixture(type: .jpeg, orientation: 6) + let image = try await ImageImportProcessor().process(input) + let source = try #require(CGImageSourceCreateWithData(image.data as CFData, nil)) + let properties = try #require(CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any]) + #expect((properties[kCGImagePropertyPixelWidth] as? NSNumber)?.intValue == 96) + #expect((properties[kCGImagePropertyPixelHeight] as? NSNumber)?.intValue == 160) + #expect(properties[kCGImagePropertyGPSDictionary] == nil) + #expect((properties[kCGImagePropertyTIFFDictionary] as? [CFString: Any])?[kCGImagePropertyTIFFArtist] == nil) + #expect(image.contentType == "image/jpeg") + } + + @Test func pngKeepsScreenshotDimensionsAndPixels() async throws { + let input = try fixture(type: .png) + let output = try await ImageImportProcessor().process(input) + func pixels(_ data: Data) throws -> Data { + let source = try #require(CGImageSourceCreateWithData(data as CFData, nil)) + let image = try #require(CGImageSourceCreateImageAtIndex(source, 0, nil)) + let context = try #require( + CGContext( + data: nil, width: 160, height: 96, bitsPerComponent: 8, bytesPerRow: 640, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)) + context.draw(image, in: CGRect(x: 0, y: 0, width: 160, height: 96)) + return Data(bytes: try #require(context.data), count: 640 * 96) + } + #expect(try pixels(input) == pixels(output.data)) + #expect(output.contentType == "image/png") + } + + @Test func corruptAndOversizedInputsAreRejected() async { + let processor = ImageImportProcessor() + await #expect(throws: ImageImportIssue.self) { try await processor.process(Data("not an image".utf8)) } + await #expect(throws: ImageImportIssue.tooLarge) { + try await processor.process(Data(count: ImageImportProcessor.maximumInputBytes + 1)) + } + } + + @Test func heicIsConvertedToJPEG() async throws { + let input = try fixture(type: .heic) + let output = try await ImageImportProcessor().process(input) + #expect(output.contentType == "image/jpeg") + #expect(output.data.prefix(2) == Data([0xff, 0xd8])) + } + + private func fixture(type: UTType, orientation: Int = 1) throws -> Data { + let context = try #require( + CGContext( + data: nil, width: 160, height: 96, bitsPerComponent: 8, bytesPerRow: 640, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)) + context.setFillColor(CGColor(red: 1, green: 1, blue: 1, alpha: 1)); + context.fill(CGRect(x: 0, y: 0, width: 160, height: 96)) + context.setFillColor(CGColor(red: 0, green: 0, blue: 0, alpha: 1)) + for x in stride(from: 8, to: 150, by: 8) { context.fill(CGRect(x: x, y: 8, width: 2, height: 60)) } + let image = try #require(context.makeImage()) + let data = NSMutableData() + let destination = try #require(CGImageDestinationCreateWithData(data, type.identifier as CFString, 1, nil)) + CGImageDestinationAddImage( + destination, image, + [ + kCGImagePropertyOrientation: orientation, + kCGImagePropertyTIFFDictionary: [kCGImagePropertyTIFFArtist: "Synthetic author"], + kCGImagePropertyGPSDictionary: [ + kCGImagePropertyGPSLatitude: 10.0, kCGImagePropertyGPSLatitudeRef: "N", + ], + ] as CFDictionary) + #expect(CGImageDestinationFinalize(destination)) + return data as Data + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Images/ImageScreenshotTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Images/ImageScreenshotTests.swift new file mode 100644 index 000000000..58189dd6e --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Images/ImageScreenshotTests.swift @@ -0,0 +1,160 @@ +import CoreGraphics +import CoreText +import Foundation +import ImageIO +import Testing +import UniformTypeIdentifiers +import Vision +@testable import MapleAppCore + +/// Entirely synthetic text at screenshot density. The source exceeds the +/// importer's maximum edge, so this fixture cannot pass by preserving its pixels. +/// Kept as drawing instructions to avoid a large binary fixture in the repository. +enum DenseScreenshotFixture { + static let width = 5_120 + static let height = 3_200 + static let anchors = [ + "MAPLE SYNTHETIC SCREENSHOT", + "Consent stays local", + "No message text in logs", + "Review every pending message", + ] + + static func png() throws -> Data { + let context = try #require( + CGContext( + data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: width * 4, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)) + context.setFillColor(CGColor(gray: 1, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + context.setFillColor(CGColor(gray: 0.94, alpha: 1)) + context.fill(CGRect(x: 48, y: height - 142, width: width - 96, height: 100)) + context.setFillColor(CGColor(gray: 0.05, alpha: 1)) + context.textMatrix = .identity + + func line(_ text: String, x: CGFloat, y: CGFloat, size: CGFloat) { + let font = CTFontCreateWithName("Menlo" as CFString, size, nil) + let attributes: [NSAttributedString.Key: Any] = [ + NSAttributedString.Key(kCTFontAttributeName as String): font, + NSAttributedString.Key(kCTForegroundColorFromContextAttributeName as String): true, + ] + let value = CTLineCreateWithAttributedString(NSAttributedString(string: text, attributes: attributes)) + context.textPosition = CGPoint(x: x, y: y) + CTLineDraw(value, context) + } + line(anchors[0], x: 80, y: CGFloat(height - 112), size: 56) + let samples = [ + "Consent stays local | Account: synthetic | Images: PNG | Queue: 16 | Draft: preserved", + "No message text in logs | Native screenshot fixture | Width 5120 | Source is synthetic", + "Review every pending message | project = local | send = explicit | owner = generation", + "0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz [] {} () + - = / : ;", + ] + for column in 0..<2 { + for row in 0..<54 { + let text = String(format: "%02d.%02d ", column + 1, row + 1) + samples[row % samples.count] + line(text, x: CGFloat(80 + column * 2_520), y: CGFloat(height - 210 - row * 50), size: 32) + } + } + line("END OF SYNTHETIC FIXTURE — no account or conversation data", x: 80, y: 120, size: 36) + let image = try #require(context.makeImage()) + let data = NSMutableData() + let destination = try #require( + CGImageDestinationCreateWithData(data, UTType.png.identifier as CFString, 1, nil)) + CGImageDestinationAddImage(destination, image, nil) + #expect(CGImageDestinationFinalize(destination)) + return data as Data + } +} + +struct ImageScreenshotTests { + @Test func denseScreenshotActuallyDownscalesToTheBoundedLosslessWireImage() async throws { + let input = try DenseScreenshotFixture.png() + #expect(input.count <= ImageImportProcessor.maximumInputBytes) + let source = try #require(CGImageSourceCreateWithData(input as CFData, nil)) + let before = try #require(CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any]) + #expect((before[kCGImagePropertyPixelWidth] as? NSNumber)?.intValue == 5_120) + #expect((before[kCGImagePropertyPixelHeight] as? NSNumber)?.intValue == 3_200) + + let output = try await ImageImportProcessor().process(input) + let result = try #require(CGImageSourceCreateWithData(output.data as CFData, nil)) + let after = try #require(CGImageSourceCopyPropertiesAtIndex(result, 0, nil) as? [CFString: Any]) + #expect(output.contentType == "image/png") + #expect(CGImageSourceGetType(result) as String? == UTType.png.identifier) + #expect(CGImageSourceGetStatus(result) == .statusComplete) + #expect((after[kCGImagePropertyPixelWidth] as? NSNumber)?.intValue == 4_096) + #expect((after[kCGImagePropertyPixelHeight] as? NSNumber)?.intValue == 2_560) + #expect(output.data.count <= ImageImportProcessor.maximumInputBytes) + #expect(output.data != input) + let beforeText = try recognize(input), afterText = try recognize(output.data) + for anchor in DenseScreenshotFixture.anchors { + #expect(beforeText.contains(normalize(anchor))) + #expect(afterText.contains(normalize(anchor))) + } + try exportSyntheticEvidence(input: input, output: output.data, recognized: afterText) + // Native fullscreen/thumbnail inspection separately establishes usable + // zoom and dismissal; OCR proves only these synthetic text anchors survive. + } + + @Test func selectedSourceSurvivesImportAndDraftCleanup() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let sourceURL = directory.appendingPathComponent("selected-source.png") + let original = try DenseScreenshotFixture.png() + try original.write(to: sourceURL, options: .withoutOverwriting) + let read = try ImageImportProcessor.readSelection(sourceURL) + let attachment = try await ImageImportProcessor().process(read) + let store = ProtectedChatStore(root: directory.appendingPathComponent("protected-drafts")) + let scope = AccountScope(apiOrigin: "http://127.0.0.1:1", principalID: "synthetic-image-source") + try await store.activate(scope) + try await store.save(ChatSnapshot(draft: ChatDraft(text: "Keep source", images: [attachment])), scope: scope) + try await store.remove(chatID: ChatSnapshot.newChatKey, scope: scope) + try await store.retire(scope) + #expect(FileManager.default.fileExists(atPath: sourceURL.path)) + #expect(try Data(contentsOf: sourceURL) == original) + } + + private func recognize(_ data: Data) throws -> String { + let request = VNRecognizeTextRequest() + request.recognitionLevel = .accurate + request.usesLanguageCorrection = false + request.recognitionLanguages = ["en-US"] + request.minimumTextHeight = 0.006 + try VNImageRequestHandler(data: data, options: [:]).perform([request]) + return normalize( + (request.results ?? []).compactMap { $0.topCandidates(1).first?.string }.joined(separator: " ")) + } + + private func normalize(_ text: String) -> String { + text.lowercased().components(separatedBy: CharacterSet.alphanumerics.inverted) + .filter { !$0.isEmpty }.joined(separator: " ") + } + + /// Explicit opt-in writes only this generated fixture. Normal test runs have + /// no persistent artifacts; no imported user image can enter this path. + private func exportSyntheticEvidence(input: Data, output: Data, recognized: String) throws { + guard let value = ProcessInfo.processInfo.environment["MAPLE_IMAGE_EVIDENCE_DIRECTORY"], !value.isEmpty else { + return + } + guard value.hasPrefix("/") else { throw CocoaError(.fileWriteInvalidFileName) } + let directory = URL(fileURLWithPath: value, isDirectory: true) + .appendingPathComponent("downscaled-screenshot-" + UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + try input.write(to: directory.appendingPathComponent("synthetic-source.png"), options: .withoutOverwriting) + try output.write(to: directory.appendingPathComponent("synthetic-imported.png"), options: .withoutOverwriting) + let manifest: [String: Any] = [ + "synthetic": true, "sourceWidth": 5120, "sourceHeight": 3200, + "outputWidth": 4096, "outputHeight": 2560, + "sourceBytes": input.count, "outputBytes": output.count, + "ocrAnchors": DenseScreenshotFixture.anchors, + "allOutputAnchorsRecognized": DenseScreenshotFixture.anchors.allSatisfy { + recognized.contains(normalize($0)) + }, + ] + try JSONSerialization.data(withJSONObject: manifest, options: [.prettyPrinted, .sortedKeys]) + .write(to: directory.appendingPathComponent("manifest.json"), options: .withoutOverwriting) + print("Synthetic image evidence: \(directory.path)") + } + +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Library/ConversationTitlePollingTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Library/ConversationTitlePollingTests.swift new file mode 100644 index 000000000..15a34bd25 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Library/ConversationTitlePollingTests.swift @@ -0,0 +1,119 @@ +import Foundation +import Testing +@testable import MapleAppCore + +private actor ConversationTitleSleepRecorder { + private(set) var delays: [Duration] = [] + let gate: ChatTestGate? + let fails: Bool + + init(gate: ChatTestGate? = nil, fails: Bool = false) { + self.gate = gate; self.fails = fails + } + + func sleep(_ duration: Duration) async throws { + delays.append(duration) + if let gate { await gate.hold() } + if fails { throw URLError(.timedOut) } + } +} + +@MainActor @Suite struct ConversationTitlePollingTests { + @Test func titleRefreshBackoffDoublesAndRetainsTenSecondCap() async { + let clock = ConversationTitleSleepRecorder() + var refreshes = 0 + await ConversationTitlePolling.run( + shouldContinue: { refreshes < 7 }, + refresh: { refreshes += 1 }, + sleep: { try await clock.sleep($0) }) + #expect(refreshes == 7) + #expect( + await clock.delays == [ + .milliseconds(500), .seconds(1), .seconds(2), .seconds(4), + .seconds(8), .seconds(10), .seconds(10), + ]) + } + + @Test func generatedTitleStopsFurtherSleepsAndRefreshes() async { + let clock = ConversationTitleSleepRecorder() + var title = "New Conversation", refreshes = 0 + await ConversationTitlePolling.run( + shouldContinue: { title == "New Conversation" }, + refresh: { + refreshes += 1 + if refreshes == 3 { title = "Generated title" } + }, + sleep: { try await clock.sleep($0) }) + #expect(title == "Generated title" && refreshes == 3) + #expect(await clock.delays == [.milliseconds(500), .seconds(1), .seconds(2)]) + } + + enum OwnershipChange: CaseIterable, Sendable { + case deselected, backgrounded, accountReplaced, taskReplaced + } + + @Test(arguments: OwnershipChange.allCases) + func ownershipChangesDuringSleepPreventTheNextRefresh(_ change: OwnershipChange) async { + let gate = ChatTestGate(), clock = ConversationTitleSleepRecorder(gate: gate) + var selectedID: String? = "chat", foreground = true + var accountGeneration = UUID(), taskTicket = UUID() + let capturedGeneration = accountGeneration, capturedTicket = taskTicket + var refreshes = 0 + let polling = Task { + await ConversationTitlePolling.run( + shouldContinue: { + selectedID == "chat" && foreground && accountGeneration == capturedGeneration + && taskTicket == capturedTicket + }, + refresh: { refreshes += 1 }, + sleep: { try await clock.sleep($0) }) + } + await gate.waitForArrival() + switch change { + case .deselected: selectedID = nil + case .backgrounded: foreground = false + case .accountReplaced: accountGeneration = UUID() + case .taskReplaced: taskTicket = UUID() + } + await gate.release(); await polling.value + #expect(refreshes == 0) + #expect(await clock.delays == [.milliseconds(500)]) + } + + @Test func cancellationDuringSleepPreventsRefreshEvenIfInjectedSleepReturnsNormally() async { + let gate = ChatTestGate(), clock = ConversationTitleSleepRecorder(gate: gate) + var refreshes = 0 + let polling = Task { + await ConversationTitlePolling.run( + shouldContinue: { true }, refresh: { refreshes += 1 }, + sleep: { try await clock.sleep($0) }) + } + await gate.waitForArrival(); polling.cancel() + await gate.release(); await polling.value + #expect(refreshes == 0) + #expect(await clock.delays == [.milliseconds(500)]) + } + + @Test func alreadyCancelledTaskDoesNotSleepOrRefresh() async { + let clock = ConversationTitleSleepRecorder() + var refreshes = 0 + let polling = Task { + await ConversationTitlePolling.run( + shouldContinue: { true }, refresh: { refreshes += 1 }, + sleep: { try await clock.sleep($0) }) + } + polling.cancel(); await polling.value + #expect(refreshes == 0) + #expect(await clock.delays.isEmpty) + } + + @Test func sleepFailureStopsWithoutFetching() async { + let clock = ConversationTitleSleepRecorder(fails: true) + var refreshes = 0 + await ConversationTitlePolling.run( + shouldContinue: { true }, refresh: { refreshes += 1 }, + sleep: { try await clock.sleep($0) }) + #expect(refreshes == 0) + #expect(await clock.delays == [.milliseconds(500)]) + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Library/InstructionOwnershipTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Library/InstructionOwnershipTests.swift new file mode 100644 index 000000000..166ba7067 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Library/InstructionOwnershipTests.swift @@ -0,0 +1,59 @@ +import Foundation +import Testing +@testable import MapleAppCore + +@MainActor @Suite struct InstructionOwnershipTests { + @Test func typingDuringReloadPreservesTextAndItsOriginalInstructionOwner() async { + let service = LibraryFixture(), gate = ChatTestGate() + await service.configure(instructions: [libraryInstruction("original", prompt: "First")]) + let editor = InstructionEditor(scope: accountScope(), destination: .account, service: service) + await editor.load() + await service.configure( + instructions: [libraryInstruction("replacement", prompt: "From reload")], listGate: gate) + let reloading = Task { await editor.load() } + await gate.waitForArrival() + editor.text = "Text typed during reload" + await gate.release(); await reloading.value + #expect(editor.text == "Text typed during reload" && editor.isDirty && !editor.isLoading) + await editor.save() + #expect(await service.instructionUpdateIDs == ["original"]) + } + + @Test func failedSavePreservesDirtyDraftAndAllowsExplicitRetry() async { + let service = LibraryFixture() + await service.configure(instructions: [libraryInstruction("default")]) + let editor = InstructionEditor(scope: accountScope(), destination: .account, service: service) + await editor.load(); editor.text = "Unsaved text" + await service.holdInstructionSave(nil, fail: true) + await editor.save() + #expect(editor.text == "Unsaved text" && editor.isDirty && !editor.didSave) + #expect(editor.issue == .connection && !editor.isSaving) + await service.holdInstructionSave(nil) + await editor.save() + #expect(editor.didSave && !editor.isDirty) + } + + @Test func invalidatingWhileSavingClearsTextAndRejectsLateSuccess() async { + let service = LibraryFixture(), gate = ChatTestGate() + await service.configure(instructions: [libraryInstruction("default")]) + await service.holdInstructionSave(gate) + let editor = InstructionEditor(scope: accountScope(), destination: .account, service: service) + await editor.load(); editor.text = "Submitted" + let saving = Task { await editor.save() } + await gate.waitForArrival() + editor.text = "Attempt to mutate submission" + #expect(editor.text == "Submitted") + editor.invalidate() + await gate.release(); await saving.value + #expect(editor.text.isEmpty && !editor.isLoaded && !editor.isSaving && !editor.didSave) + } + + @Test func multipleDefaultsFailClosedAndNeverChooseListOrder() async { + let service = LibraryFixture() + await service.configure(instructions: [libraryInstruction("a"), libraryInstruction("b")]) + let editor = InstructionEditor(scope: accountScope(), destination: .account, service: service) + await editor.load(); editor.text = "Replacement"; await editor.save() + #expect(editor.issue == .invalidResponse && !editor.isLoaded) + #expect(await service.instructionChanges.isEmpty) + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Library/LibraryFixtures.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Library/LibraryFixtures.swift new file mode 100644 index 000000000..2a2f9288c --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Library/LibraryFixtures.swift @@ -0,0 +1,188 @@ +import Foundation +import MapleSDK +@testable import MapleAppCore + +func libraryConversation(_ id: String, project: String? = nil, pinned: Bool = false) -> Conversation { + Conversation( + id: id, object: "conversation", createdAt: 0, metadata: ["title": .string("Title \(id)")], projectId: project, + pinned: pinned, lastActivityAt: 0) +} +func libraryInstruction(_ id: String, prompt: String = "original", isDefault: Bool = true) -> Instruction { + Instruction( + id: id, object: "instruction", name: "User Preferences", prompt: prompt, promptTokens: 2, isDefault: isDefault, + createdAt: 0, updatedAt: 0) +} +actor LibraryFixture: LibraryServing { + var conversations: [Conversation] = [] + var instructions: [Instruction] = [] + var projectInstructions: String? = "project original" + var deleteCalls: [[String]] = [] + var moveCalls: [[String]] = [] + var moveGate: ChatTestGate? + var failedMoveCall: Int? + var projectDeletes: [String] = [] + var deleteAllCalls = 0 + var instructionChanges: [String] = [] + var updateMetadata: [String: JSONValue]? + var conversationUpdateIDs: [String] = [] + var malformedConversationUpdate = false + var failedDeletes: Set = [] + var failList = false + var failDelete = false + var listGate: ChatTestGate? + var projectDiscoveryGate: ChatTestGate? + var repeatedCursor = false + var updateGate: ChatTestGate? + var instructionSaveGate: ChatTestGate? + var instructionSaveFailure = false + var instructionUpdateIDs: [String] = [] + var conversationListCalls: [ConversationsListParameters] = [] + var projects = [ + ConversationProjectListItem(id: "project", object: "project", name: "Project", createdAt: 0, updatedAt: 0) + ] + var projectsGate: ChatTestGate? + var projectListCalls: [ListParameters] = [] + func configureProjects(_ values: [ConversationProjectListItem], gate: ChatTestGate? = nil) { + projects = values; projectsGate = gate + } + func holdUpdate(_ gate: ChatTestGate?) { updateGate = gate } + func returnMalformedConversationUpdate() { malformedConversationUpdate = true } + func configureMove(gate: ChatTestGate? = nil, failedCall: Int? = nil) { + moveGate = gate; failedMoveCall = failedCall + } + func holdInstructionSave(_ gate: ChatTestGate?, fail: Bool = false) { + instructionSaveGate = gate; instructionSaveFailure = fail + } + func configure( + conversations: [Conversation]? = nil, instructions: [Instruction]? = nil, + failedDeletes: Set = [], failList: Bool = false, failDelete: Bool = false, + listGate: ChatTestGate? = nil, projectDiscoveryGate: ChatTestGate? = nil, repeatedCursor: Bool = false + ) { + if let conversations { self.conversations = conversations } + if let instructions { self.instructions = instructions } + self.failedDeletes = failedDeletes; self.failList = failList; self.failDelete = failDelete + self.listGate = listGate; self.projectDiscoveryGate = projectDiscoveryGate; self.repeatedCursor = repeatedCursor + } + private func page(_ values: [T], ids: [String], parameters: ListParameters) + -> APIPage + { + let start = parameters.after.flatMap { ids.firstIndex(of: $0).map { $0 + 1 } } ?? 0 + let end = min(values.count, start + (parameters.limit ?? 100)) + let data = Array(values[start.. start ? ids[end - 1] : nil, + hasMore: end < values.count) + } + func listConversations(_ parameters: ConversationsListParameters) async throws -> APIPage { + conversationListCalls.append(parameters) + if failList { throw URLError(.notConnectedToInternet) } + let rows = conversations.filter { + (parameters.projectId == nil || $0.projectId == parameters.projectId) + && (parameters.unassignedProject != true || $0.projectId == nil) + && (parameters.pinned == nil || $0.pinned == parameters.pinned) + } + let result = page(rows, ids: rows.map(\.id), parameters: parameters.page) + if let listGate { await listGate.hold() } + if parameters.projectId != nil, let projectDiscoveryGate { await projectDiscoveryGate.hold() } + if repeatedCursor { return APIPage(data: Array(rows.prefix(1)), lastId: "same", hasMore: true) } + return result + } + func getConversation(id: String) throws -> Conversation { try require(conversations.first { $0.id == id }) } + func updateConversation( + id: String, metadata: [String: JSONValue]?, projectId: NullableUpdate, pinned: Bool? + ) async throws -> Conversation { + conversationUpdateIDs.append(id) + if let updateGate { await updateGate.hold() } + let index = try require(conversations.firstIndex { $0.id == id }) + updateMetadata = metadata + if let metadata { conversations[index].metadata = metadata } + if let pinned { conversations[index].pinned = pinned } + switch projectId { + case .clear: conversations[index].projectId = nil; + case .set(let id): conversations[index].projectId = id; + case .unchanged: break + } + if malformedConversationUpdate { return libraryConversation("wrong-id") } + return conversations[index] + } + func deleteConversation(id: String) throws -> DeletedObject { + if failDelete { throw URLError(.cannotConnectToHost) } + conversations.removeAll { $0.id == id }; return DeletedObject(id: id, object: "conversation", deleted: true) + } + func deleteAllConversations() throws -> DeletedObject { + deleteAllCalls += 1 + if failDelete { throw URLError(.cannotConnectToHost) } + conversations = []; return DeletedObject(object: "conversation", deleted: true) + } + func batchDeleteConversations(ids: [String]) throws -> BatchDeleteResponse { + guard ids.count <= 20 else { throw ResearchAPIError.invalidArgument } + deleteCalls.append(ids) + if failDelete { throw URLError(.cannotConnectToHost) } + conversations.removeAll { ids.contains($0.id) && !failedDeletes.contains($0.id) } + return BatchDeleteResponse( + object: "list", + data: ids.map { BatchDeleteItem(id: $0, object: "conversation", deleted: !failedDeletes.contains($0)) }) + } + func batchUpdateConversationProject(ids: [String], projectId: String?) async throws -> BatchUpdateProjectResponse { + guard ids.count <= 20 else { throw ResearchAPIError.invalidArgument } + moveCalls.append(ids) + if let moveGate { await moveGate.hold() } + if failedMoveCall == moveCalls.count { throw URLError(.cannotConnectToHost) } + for index in conversations.indices where ids.contains(conversations[index].id) { + conversations[index].projectId = projectId + } + return BatchUpdateProjectResponse(success: true) + } + func listProjects(_ parameters: ListParameters) async -> APIPage { + projectListCalls.append(parameters) + let result = page(projects, ids: projects.map(\.id), parameters: parameters) + if let projectsGate { await projectsGate.hold() } + return result + } + func getProject(id: String) -> ConversationProject { + ConversationProject( + id: id, object: "project", name: "Project", instructions: projectInstructions, createdAt: 0, updatedAt: 0) + } + func createProject(name: String) -> ConversationProject { + ConversationProject(id: "created-project", object: "project", name: name, createdAt: 0, updatedAt: 0) + } + func updateProject(id: String, name: String?, instructions: NullableUpdate) -> ConversationProject { + switch instructions { + case .clear: projectInstructions = nil; + case .set(let value): projectInstructions = value; + case .unchanged: break + } + return ConversationProject( + id: id, object: "project", name: name ?? "Project", instructions: projectInstructions, createdAt: 0, + updatedAt: 0) + } + func deleteProject(id: String) throws -> DeletedObject { + projectDeletes.append(id) + if failDelete { throw URLError(.cannotConnectToHost) } + conversations.removeAll { $0.projectId == id }; return DeletedObject(id: id, object: "project", deleted: true) + } + func listInstructions(_ parameters: ListParameters) async throws -> APIPage { + if failList { throw URLError(.cannotConnectToHost) } + if let listGate { await listGate.hold() } + return page(instructions, ids: instructions.map(\.id), parameters: parameters) + } + func createInstruction(name: String, prompt: String, isDefault: Bool?) -> Instruction { + instructionChanges.append("create"); + let value = libraryInstruction("created", prompt: prompt, isDefault: isDefault ?? false); + instructions.append(value); return value + } + func updateInstruction(id: String, name: String?, prompt: String?, isDefault: Bool?) async throws -> Instruction { + instructionUpdateIDs.append(id) + if let instructionSaveGate { await instructionSaveGate.hold() } + if instructionSaveFailure { throw URLError(.cannotConnectToHost) } + instructionChanges.append("update"); + return libraryInstruction(id, prompt: prompt ?? "", isDefault: isDefault ?? true) + } + func deleteInstruction(id: String) -> DeletedObject { + instructionChanges.append("delete"); instructions.removeAll { $0.id == id }; + return DeletedObject(id: id, object: "instruction", deleted: true) + } + private func require(_ value: T?) throws -> T { + guard let value else { throw ResearchAPIError.invalidResponse }; return value + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Library/LibraryMenuModelTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Library/LibraryMenuModelTests.swift new file mode 100644 index 000000000..7ccb615ef --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Library/LibraryMenuModelTests.swift @@ -0,0 +1,231 @@ +import Foundation +import MapleSDK +import Testing +@testable import MapleAppCore + +@MainActor @Suite struct LibraryMenuModelTests { + private func fixture() -> (LibraryMenuModel, LibraryFixture) { + let scope = AccountScope(apiOrigin: "http://127.0.0.1:3000", principalID: "menu-fixture") + let store = ChatFixtureStore(), service = LibraryFixture(), chat = ChatFixtureService(store: store) + let deletion = ChatDeletionCoordinator(scope: scope, retained: [], service: chat, persistence: store) + let root = LibraryModel(scope: scope, service: service, deletions: deletion) + return ( + LibraryMenuModel(root: root) { + LibraryModel(scope: scope, service: service, deletions: deletion) + }, service + ) + } + + private func folder(_ id: String) -> ConversationProjectListItem { + ConversationProjectListItem(id: id, object: "project", name: id, createdAt: 0, updatedAt: 0) + } + + @Test func launchPreloadsOnlyFirstPagesAndRestoredOpenFolders() async { + let (menu, service) = fixture() + await service.configureProjects([folder("open"), folder("closed")]) + let recent = (0..<45).map { libraryConversation("recent-\($0)") } + let open = (0..<105).map { libraryConversation("open-\($0)", project: "open") } + await service.configure( + conversations: recent + open + [ + libraryConversation("closed-chat", project: "closed"), libraryConversation("pinned", pinned: true), + ]) + await menu.prepare { $0 == "open" } + #expect(menu.isPrepared && menu.root.projects.count == 2) + #expect(menu.pinned.visibleConversations.map(\.id) == ["pinned"]) + #expect(menu.recent.conversations == Array(recent.prefix(20)) && menu.recent.hasMore) + #expect(menu.projects["open"]?.conversations == Array(open.prefix(100))) + #expect(menu.projects["closed"]?.hasLoadedConversations == false) + #expect(await service.conversationListCalls.count == 3) + #expect(await service.conversationListCalls.allSatisfy { $0.page.after == nil }) + await menu.prepare { _ in true } + #expect(await service.conversationListCalls.count == 3) + } + + @Test func overlappingLaunchAndMenuPreparationShareRequestsAndSnapshots() async { + let (menu, service) = fixture(), gate = ChatTestGate() + await service.configure(conversations: [libraryConversation("chat")], listGate: gate) + let first = Task { await menu.prepare { _ in false } } + await gate.waitForArrival() + let second = Task { await menu.prepare { _ in false } } + await Task.yield(); await gate.release() + await first.value; await second.value + #expect(await service.conversationListCalls.count == 2) + #expect(await service.projectListCalls.count == 1) + let retained = menu.recent + await menu.prepare { _ in false } + #expect(menu.recent === retained && menu.recent.visibleConversations.map(\.id) == ["chat"]) + } + + @Test func retirementDuringPreloadCannotRepopulateMenu() async { + let (menu, service) = fixture(), gate = ChatTestGate() + await service.configure(conversations: [libraryConversation("private")], listGate: gate) + let loading = Task { await menu.prepare { _ in true } } + await gate.waitForArrival(); menu.invalidate(); menu.root.invalidate() + await gate.release(); await loading.value + #expect(menu.projects.isEmpty && menu.recent.conversations.isEmpty && menu.pinned.conversations.isEmpty) + #expect(!menu.isPrepared) + await menu.prepare { _ in true } + #expect(menu.projects.isEmpty) + } + + @Test func refreshRetainsLoadedDepthAndFolderOwnersWithoutFetchingClosedFolders() async { + let (menu, service) = fixture() + await service.configureProjects([folder("open"), folder("closed")]) + await service.configure(conversations: (0..<45).map { libraryConversation(String($0)) }) + await menu.prepare { _ in false } + let recent = menu.recent, closed = menu.project("closed") + await recent.loadMore() + await menu.refresh { _ in false } + #expect(menu.recent === recent && menu.project("closed") === closed) + #expect(recent.conversations.count == 30 && recent.hasMore) + #expect(!closed.hasLoadedConversations) + #expect(await service.conversationListCalls.filter { $0.projectId != nil }.isEmpty) + #expect( + await service.conversationListCalls.filter { $0.unassignedProject == true }.map(\.page.limit) + == [20, 10, 20, 10]) + } + + @Test func searchChangedDuringFolderReadPublishesOnlyCurrentFilter() async { + let (menu, service) = fixture(), gate = ChatTestGate() + await service.configureProjects([folder("closed")]) + await service.configure(conversations: [libraryConversation("one", project: "closed")]) + await menu.prepare { _ in false } + await service.configure(listGate: gate) + menu.setSearch("Title one") + await gate.waitForArrival() + menu.setSearch("") + await gate.release() + await menu.refresh { _ in true } + #expect(menu.searchText.isEmpty && menu.project("closed").searchText.isEmpty) + #expect(menu.project("closed").visibleConversations.map(\.id) == ["one"]) + #expect(await service.conversationListCalls.filter { $0.projectId == "closed" }.count <= 2) + } + + @Test func hiddenMenuCancelsSearchReadsAndResumesWithoutDiscardingQuery() async { + let (menu, service) = fixture(), gate = ChatTestGate() + await service.configureProjects([folder("first"), folder("second")]) + await service.configure(conversations: [ + libraryConversation("one", project: "first"), libraryConversation("two", project: "second"), + ]) + await menu.prepare { _ in false } + await service.configure(listGate: gate) + menu.setSearch("Title") + await gate.waitForArrival(); menu.cancelSearchLoading(); await gate.release() + await menu.refresh { _ in false } + #expect(menu.searchText == "Title" && menu.project("first").searchText == "Title") + #expect(await service.conversationListCalls.filter { $0.projectId == "first" }.count == 1) + #expect(await service.conversationListCalls.filter { $0.projectId == "second" }.isEmpty) + await service.configure(listGate: nil) + menu.setSearch(menu.searchText) + await menu.refresh { _ in false } + #expect(menu.project("first").visibleConversations.map(\.id) == ["one"]) + #expect(menu.project("second").visibleConversations.map(\.id) == ["two"]) + } + + @Test func visibleMenuPollsOnlyRecentsOnceWithoutRecreatingItsOwners() async { + let (menu, service) = fixture(), first = ChatTestGate(), next = ChatTestGate() + await service.configure(conversations: [libraryConversation("old")]) + await menu.prepare { _ in false } + let recent = menu.recent, pinned = menu.pinned + let clock = LibraryRecentsSleepRecorder(gates: [first, next]) + menu.updateRecentsPolling(visible: true, foreground: true, sleep: { try await clock.sleep($0) }) + await first.waitForArrival() + menu.updateRecentsPolling(visible: true, foreground: true, sleep: { try await clock.sleep($0) }) + await service.configure(conversations: [libraryConversation("new"), libraryConversation("old")]) + await first.release(); await next.waitForArrival() + #expect(menu.recent === recent && menu.pinned === pinned) + #expect(menu.recent.conversations.map(\.id) == ["new", "old"]) + #expect(await service.conversationListCalls.count == 3) + #expect(await service.projectListCalls.count == 1) + #expect(await clock.delays == [.seconds(60), .seconds(60)]) + menu.updateRecentsPolling(visible: false, foreground: true) + await next.release() + } + + @Test func failedPollingClockCanRestartWithoutHidingTheMenu() async { + let (menu, service) = fixture(), sleeping = ChatTestGate() + let failedClock = LibraryRecentsSleepRecorder(fails: true) + menu.updateRecentsPolling(visible: true, foreground: true, sleep: { try await failedClock.sleep($0) }) + let restartedClock = LibraryRecentsSleepRecorder(gates: [sleeping]) + // Actor turns make progress without depending on an elapsed wall-clock + // delay. A retained completed task would prevent every restart attempt. + for _ in 0..<100 { + menu.updateRecentsPolling(visible: true, foreground: true, sleep: { try await restartedClock.sleep($0) }) + if !(await restartedClock.delays).isEmpty { break } + await Task.yield() + } + #expect(await failedClock.delays == [.seconds(60)]) + #expect(await restartedClock.delays == [.seconds(60)]) + #expect(await service.conversationListCalls.isEmpty) + menu.updateRecentsPolling(visible: false, foreground: true) + await sleeping.release(); menu.invalidate() + } + + @Test func oldPollCompletionCannotClearItsVisibleReplacement() async { + let (menu, service) = fixture(), oldSleep = ChatTestGate(), newSleep = ChatTestGate() + let oldClock = LibraryRecentsSleepRecorder(gates: [oldSleep]) + menu.updateRecentsPolling(visible: true, foreground: true, sleep: { try await oldClock.sleep($0) }) + await oldSleep.waitForArrival() + menu.updateRecentsPolling(visible: false, foreground: true) + let newClock = LibraryRecentsSleepRecorder(gates: [newSleep]) + menu.updateRecentsPolling(visible: true, foreground: true, sleep: { try await newClock.sleep($0) }) + await newSleep.waitForArrival(); await oldSleep.release() + let unexpectedClock = LibraryRecentsSleepRecorder(fails: true) + for _ in 0..<20 { + menu.updateRecentsPolling(visible: true, foreground: true, sleep: { try await unexpectedClock.sleep($0) }) + _ = await unexpectedClock.delays + await Task.yield() + } + #expect(await newClock.delays == [.seconds(60)]) + #expect(await unexpectedClock.delays.isEmpty) + #expect(await service.conversationListCalls.isEmpty) + menu.updateRecentsPolling(visible: false, foreground: true) + await newSleep.release(); menu.invalidate() + } + + enum RecentsOwnerChange: CaseIterable, Sendable, Equatable { case hidden, backgrounded, retired } + + @Test(arguments: RecentsOwnerChange.allCases) + func recentsOwnershipLossDuringReadCannotPublishLateRows(_ change: RecentsOwnerChange) async { + let (menu, service) = fixture(), request = ChatTestGate(), sleeping = ChatTestGate() + await service.configure(conversations: [libraryConversation("old")]); await menu.prepare { _ in false } + let clock = LibraryRecentsSleepRecorder(gates: [sleeping]) + menu.updateRecentsPolling(visible: true, foreground: true, sleep: { try await clock.sleep($0) }) + await sleeping.waitForArrival() + await service.configure(conversations: [libraryConversation("late")], listGate: request) + let refreshing = Task { await menu.refreshRecents() } + await request.waitForArrival() + switch change { + case .hidden: menu.updateRecentsPolling(visible: false, foreground: true) + case .backgrounded: menu.updateRecentsPolling(visible: true, foreground: false) + case .retired: menu.invalidate() + } + await request.release(); await refreshing.value; await sleeping.release() + #expect(menu.recent.conversations.map(\.id) == (change == .retired ? [] : ["old"])) + } + + @Test func conversationCreatedRefreshesHiddenRecentsButNotBackgroundedOrRetiredMenu() async { + let (menu, service) = fixture() + await service.configure(conversations: [libraryConversation("old")]); await menu.prepare { _ in false } + await service.configure(conversations: [libraryConversation("created"), libraryConversation("old")]) + await menu.refreshRecents() + #expect(menu.recent.conversations.map(\.id) == ["created", "old"]) + menu.updateRecentsPolling(visible: false, foreground: false) + await menu.refreshRecents() + menu.invalidate(); await menu.refreshRecents() + #expect(await service.conversationListCalls.count == 3) + } + + @Test func hidingMenuDoesNotCancelExplicitLoadedWindowRefresh() async { + let (menu, service) = fixture(), request = ChatTestGate(), sleeping = ChatTestGate() + await service.configure(conversations: [libraryConversation("old")]); await menu.prepare { _ in false } + let clock = LibraryRecentsSleepRecorder(gates: [sleeping]) + menu.updateRecentsPolling(visible: true, foreground: true, sleep: { try await clock.sleep($0) }) + await sleeping.waitForArrival() + await service.configure(conversations: [libraryConversation("fresh")], listGate: request) + let refreshing = Task { await menu.refresh { _ in false } } + await request.waitForArrival(); menu.updateRecentsPolling(visible: false, foreground: true) + await request.release(); await refreshing.value; await sleeping.release() + #expect(menu.recent.conversations.map(\.id) == ["fresh"]) + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Library/LibraryModelTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Library/LibraryModelTests.swift new file mode 100644 index 000000000..0a66b82e1 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Library/LibraryModelTests.swift @@ -0,0 +1,416 @@ +import Foundation +import MapleSDK +import Observation +import Synchronization +import Testing +@testable import MapleAppCore + +private final class LibraryObservationChanges: Sendable { + private let count = Mutex(0) + var value: Int { count.withLock { $0 } } + func record() { count.withLock { $0 += 1 } } +} + +@MainActor @Suite struct LibraryModelTests { + func fixture() -> (LibraryModel, LibraryFixture, ChatDeletionCoordinator, ChatFixtureService, ChatFixtureStore) { + let scope = AccountScope(apiOrigin: "http://127.0.0.1:3000", principalID: "fixture") + let store = ChatFixtureStore(), service = LibraryFixture(), chat = ChatFixtureService(store: store) + let deletion = ChatDeletionCoordinator(scope: scope, retained: [], service: chat, persistence: store) + return (LibraryModel(scope: scope, service: service, deletions: deletion), service, deletion, chat, store) + } + @Test func emptyFirstPageCompletionNotifiesReadinessObservers() async { + let (model, service, _, _, _) = fixture(), gate = ChatTestGate() + await service.configure(conversations: [], listGate: gate) + let load = Task { await model.load(.project("project")) } + await gate.waitForArrival() + let changes = LibraryObservationChanges() + let initiallyReady = withObservationTracking { + model.hasLoadedConversations + } onChange: { + changes.record() + } + #expect(!initiallyReady && changes.value == 0) + await gate.release(); await load.value + #expect(model.hasLoadedConversations && changes.value == 1) + #expect(model.conversations.isEmpty && !model.hasMore && model.issue == nil) + } + @Test func searchUsesLoadedTitlesAndPagingPreservesUniqueRows() async { + let (model, service, _, _, _) = fixture() + await service.configure(conversations: (0..<102).map { libraryConversation(String($0)) }) + await model.load() + #expect(model.conversations.count == 100 && model.hasMore) + model.searchText = "Title 101" + #expect(model.visibleConversations.isEmpty) + await model.loadMore() + #expect(model.visibleConversations.map(\.id) == ["101"]) + #expect(!model.hasMore && model.conversations.count == 102) + } + @Test func renamePreservesCreationCorrelationAndOtherMetadata() async { + let (model, service, _, _, _) = fixture() + var original = libraryConversation("a") + original.metadata?["internal_native_creation_id"] = .string(String(repeating: "a", count: 64)) + original.metadata?["preference"] = .bool(true) + await service.configure(conversations: [original]) + await model.load() + await model.renameConversation(id: "a", title: "Renamed") + #expect(model.conversations.first?.metadata?["title"] == .string("Renamed")) + #expect( + await service.updateMetadata?["internal_native_creation_id"] + == original.metadata?["internal_native_creation_id"]) + #expect(await service.updateMetadata?["preference"] == .bool(true)) + } + @Test func bulkActionsUseTwentyIDContractAndKeepFailedSelection() async { + let (model, service, _, _, _) = fixture() + let rows = (0..<45).map { libraryConversation(String($0)) }, ids = Set(rows.map(\.id)) + await service.configure(conversations: rows, failedDeletes: ["3"]) + await model.load(); model.selectedIDs = ids + let moved = await model.moveConversations(ids: ids, projectID: "project") + #expect(moved) + #expect(await service.moveCalls.map(\.count) == [20, 20, 5]) + #expect(model.conversations.allSatisfy { $0.projectId == "project" }) + #expect(model.selectedIDs.isEmpty) + model.selectedIDs = ids + let deleted = await model.deleteConversations(ids: ids) + #expect(!deleted) + #expect(await service.deleteCalls.map(\.count) == [20, 20, 5]) + #expect(model.conversations.map(\.id) == ["3"]) + #expect(model.selectedIDs == ["3"] && model.issue == .partialMutation) + } + @Test func bulkDeleteSignalsSuccessOnlyAfterEverySelectedChatIsDeleted() async { + let (model, service, _, _, _) = fixture() + let rows = (0..<25).map { libraryConversation(String($0)) }, ids = Set(rows.map(\.id)) + await service.configure(conversations: rows) + await model.load(); model.selectedIDs = ids + #expect(await model.deleteConversations(ids: ids)) + #expect(await service.deleteCalls.map(\.count) == [20, 5]) + #expect(model.selectedIDs.isEmpty && model.conversations.isEmpty && model.issue == nil) + } + @Test func failedBulkDeleteKeepsSelectionAndDoesNotSignalSuccess() async { + let (model, service, _, _, _) = fixture() + await service.configure(conversations: [libraryConversation("a")], failDelete: true) + await model.load(); model.selectedIDs = ["a"] + #expect(await model.deleteConversations(ids: ["a"]) == false) + #expect(model.selectedIDs == ["a"] && model.conversations.map(\.id) == ["a"]) + #expect(model.issue == .connection) + model.invalidate() + #expect(await model.deleteConversations(ids: ["a"]) == false) + #expect(await service.deleteCalls.count == 1) + } + @Test func projectDiscoveryFencesBeforeAwaitAndFailureReleasesWithoutDeleting() async throws { + let (model, service, deletion, chat, store) = fixture(), gate = ChatTestGate() + let runtime = ChatRuntime(scope: model.scope, service: chat, persistence: store, conversationID: "a") + deletion.register(runtime, projectID: "unrelated") + runtime.draftText = "still editable" + await service.configure( + conversations: [libraryConversation("a", project: "project")], failDelete: true, projectDiscoveryGate: gate) + let request = Task { await model.deleteProject(id: "project") } + await gate.waitForArrival() + #expect(!runtime.canSend && !runtime.send()) + runtime.draftText = "edited during discovery" + await gate.release(); await request.value + #expect(runtime.canSend && runtime.draftText == "edited during discovery") + #expect(await service.projectDeletes == ["project"]) + #expect(model.issue == .connection) + } + @Test func malformedRepeatedCursorBlocksProjectDelete() async { + let (model, service, deletion, _, _) = fixture() + await service.configure(conversations: [libraryConversation("a", project: "project")], repeatedCursor: true) + await model.deleteProject(id: "project") + #expect(model.issue == .invalidResponse) + #expect(await service.projectDeletes.isEmpty) + #expect(!deletion.hasActiveFence) + } + @Test func accountFenceBlocksNewLibraryMutations() async throws { + let (model, service, deletion, _, _) = fixture() + await service.configure(conversations: [libraryConversation("a")]) + let lease = try deletion.begin(.all) + await model.renameConversation(id: "a", title: "late") + #expect(model.issue == .busy) + #expect(await service.updateMetadata == nil) + deletion.release(lease) + } + + @Test func projectMoveReturnsFalseForFailedOrRetiredAcknowledgements() async { + let (model, service, _, _, _) = fixture(), gate = ChatTestGate() + await service.configureMove(failedCall: 1) + let failed = await model.moveConversations(ids: ["chat"], projectID: "project") + #expect(!failed && model.issue == .connection) + await service.configureMove(gate: gate) + let moving = Task { await model.moveConversations(ids: ["chat"], projectID: "project") } + await gate.waitForArrival(); model.invalidate(); await gate.release() + #expect(await moving.value == false) + } + @Test func retiredHistoryResultCannotPublish() async { + let (model, service, _, _, _) = fixture(), gate = ChatTestGate() + await service.configure(conversations: [libraryConversation("a")], listGate: gate) + let loading = Task { await model.load() } + await gate.waitForArrival(); model.invalidate(); await gate.release(); await loading.value + #expect(model.conversations.isEmpty && !model.isLoading) + } + + @Test func recentExcludesPinnedAndProjectChatsAtThePageBoundary() async { + let (model, service, _, _, _) = fixture() + await service.configure(conversations: [ + libraryConversation("pin", pinned: true), libraryConversation("recent"), + libraryConversation("project", project: "folder"), + libraryConversation("project-pin", project: "folder", pinned: true), + ]) + await model.load(.recent) + #expect(model.visibleConversations.map(\.id) == ["recent"]) + #expect(await service.conversationListCalls.last?.pinned == false) + #expect(await service.conversationListCalls.last?.unassignedProject == true) + var pinned = libraryConversation("recent", pinned: true) + model.receiveConversationUpdate(pinned, from: model.scope) + #expect(model.visibleConversations.isEmpty) + pinned.pinned = false + model.receiveConversationUpdate(pinned, from: model.scope) + #expect(model.visibleConversations.map(\.id) == ["recent"]) + } + + @Test func recentMatchesWebDesktopTwentyThenTenCursorPagination() async { + let (model, service, _, _, _) = fixture() + let rows = (0..<37).map { libraryConversation(String($0)) } + await service.configure(conversations: rows + [libraryConversation("pin", pinned: true)]) + await model.load(.recent) + #expect(model.conversations == Array(rows.prefix(20)) && model.hasMore) + await model.loadMoreIfNeeded(visibleID: "0") + #expect(await service.conversationListCalls.count == 1) + await model.loadMoreIfNeeded(visibleID: "19") + #expect(model.conversations == Array(rows.prefix(30)) && model.hasMore) + await model.loadMoreIfNeeded(visibleID: "29") + #expect(model.conversations == rows && !model.hasMore) + #expect(await service.conversationListCalls.map(\.page.limit) == [20, 10, 10]) + #expect(await service.conversationListCalls.map(\.page.after) == [nil, "19", "29"]) + #expect(await service.conversationListCalls.allSatisfy { $0.pinned == false && $0.unassignedProject == true }) + } + + @Test func recentVisibleBoundaryDeduplicatesOverlapAndDoesNotAutoPageSearch() async { + let (model, service, _, _, _) = fixture(), gate = ChatTestGate() + let rows = (0..<35).map { libraryConversation(String($0)) } + await service.configure(conversations: rows) + await model.load(.recent) + model.searchText = "19" + await model.loadMoreIfNeeded(visibleID: "19") + #expect(await service.conversationListCalls.count == 1) + model.searchText = "" + await service.configure(listGate: gate) + let first = Task { await model.loadMoreIfNeeded(visibleID: "19") } + await gate.waitForArrival() + await model.loadMoreIfNeeded(visibleID: "18") + await gate.release(); await first.value + #expect(model.conversations.count == 30 && Set(model.conversations.map(\.id)).count == 30) + #expect(await service.conversationListCalls.map(\.page.after) == [nil, "19"]) + } + + @Test func movingLastProjectChatRemovesItsCachedMembershipImmediately() async { + let (model, service, _, _, _) = fixture() + await service.configure(conversations: [libraryConversation("a", project: "folder")]) + await model.load(.project("folder")); model.selectedIDs = ["a"] + await model.moveConversations(ids: ["a"], projectID: nil) + #expect(model.conversations.isEmpty && model.selectedIDs.isEmpty && model.issue == nil) + #expect(await service.conversations.first?.projectId == nil) + } + + @Test func unchangedRefreshRetainsRowsSelectionAndAllLoadedPagesWithoutPublishing() async { + let (model, service, _, _, _) = fixture(), gate = ChatTestGate() + let rows = (0..<205).map { libraryConversation(String($0)) } + await service.configure(conversations: rows) + await model.load(); await model.loadMore() + model.selectedIDs = ["150"]; model.searchText = "Title" + let changes = LibraryObservationChanges() + _ = withObservationTracking { + model.conversations + } onChange: { + changes.record() + } + await service.configure(listGate: gate) + let refresh = Task { await model.load() } + await gate.waitForArrival() + #expect(model.conversations == Array(rows.prefix(200)) && model.hasMore) + await gate.release(); await refresh.value + #expect(changes.value == 0) + #expect(model.selectedIDs == ["150"] && model.searchText == "Title") + #expect(await service.conversationListCalls.map(\.page.after) == [nil, "99", nil, "99"]) + await service.configure(listGate: nil) + await model.loadMore() + #expect(model.conversations == rows && !model.hasMore) + } + + @Test func refreshReconcilesChangedLoadedPagesAtomically() async { + let (model, service, _, _, _) = fixture() + let rows = (0..<202).map { libraryConversation(String($0)) } + await service.configure(conversations: rows) + await model.load(); await model.loadMore() + var changed = Array(rows.dropFirst()); changed[120].metadata = ["title": .string("Renamed elsewhere")] + changed.insert(libraryConversation("new"), at: 0) + await service.configure(conversations: changed) + await model.refresh() + #expect(model.conversations == Array(changed.prefix(200)) && model.hasMore) + await model.loadMore() + #expect(model.conversations == changed) + } + + @Test func recentsPollMergesServerOrderWithoutReloadingOlderPagesOrChangingSelection() async { + let (model, service, _, _, _) = fixture() + let rows = (0..<45).map { libraryConversation(String($0)) } + await service.configure(conversations: rows) + await model.load(.recent); await model.loadMore() + model.selectedIDs = ["25"]; model.searchText = "Title" + var renamed = rows[1]; renamed.metadata = ["title": .string("Renamed elsewhere")] + await service.configure( + conversations: [libraryConversation("new"), renamed, rows[0]] + Array(rows.dropFirst(2))) + await model.pollRecentConversations() + #expect(model.conversations.map(\.id) == ["new", "1", "0"] + (2..<30).map(String.init)) + #expect(model.conversations[1] == renamed && model.selectedIDs == ["25"] && model.searchText == "Title") + #expect(model.hasMore && !model.isLoading) + await model.loadMore() + #expect(model.conversations.count == 41) + #expect(await service.conversationListCalls.map(\.page.limit) == [20, 10, 20, 10]) + #expect(await service.conversationListCalls.map(\.page.after) == [nil, "19", nil, "29"]) + } + + @Test func unchangedRecentsTitleKeepsCachedValueAndDoesNotInvalidateRows() async { + let (model, service, _, _, _) = fixture(), original = libraryConversation("one") + await service.configure(conversations: [original]); await model.load(.recent) + let changes = LibraryObservationChanges() + _ = withObservationTracking { + model.conversations + } onChange: { + changes.record() + } + var sameTitle = original + sameTitle.metadata?["other"] = .string("changed"); sameTitle.lastActivityAt = 20 + await service.configure(conversations: [sameTitle]); await model.pollRecentConversations() + #expect(model.conversations == [original] && changes.value == 0) + sameTitle.metadata?["title"] = .string("New title") + await service.configure(conversations: [sameTitle]); await model.pollRecentConversations() + #expect(model.conversations == [sameTitle] && changes.value == 1) + } + + @Test(arguments: [true, false]) + func failedOrEmptyRecentsTickRetainsRowsAndExistingIssue(_ fails: Bool) async { + let (model, service, _, _, _) = fixture(), rows = [libraryConversation("one")] + await service.configure(conversations: rows); await model.load(.recent) + model.selectedIDs = ["one"]; await model.renameConversation(id: "one", title: "") + await service.configure(conversations: [], failList: fails); await model.pollRecentConversations() + #expect(model.conversations == rows && model.selectedIDs == ["one"] && model.issue == .invalidInput) + } + + @Test func manualRefreshSupersedesRecentsReadAlreadyInFlight() async { + let (model, service, _, _, _) = fixture(), gate = ChatTestGate() + await service.configure(conversations: [libraryConversation("old")]); await model.load(.recent) + let poll = Task { + await service.configure(conversations: [libraryConversation("stale")], listGate: gate) + await model.pollRecentConversations() + } + await gate.waitForArrival() + await service.configure(conversations: [libraryConversation("fresh")]) + await model.refresh() + await gate.release(); await poll.value + #expect(model.conversations.map(\.id) == ["fresh"]) + } + + enum RecentsChange: CaseIterable, Sendable { case renamed, deleted, retired, cancelled } + + @Test(arguments: RecentsChange.allCases) + func staleRecentsReadCannotReverseAccountChanges(_ change: RecentsChange) async { + let (model, service, _, _, _) = fixture(), gate = ChatTestGate(), original = libraryConversation("one") + await service.configure(conversations: [original]); await model.load(.recent) + await service.configure(conversations: [libraryConversation("stale"), original], listGate: gate) + let poll = Task { await model.pollRecentConversations() } + await gate.waitForArrival() + switch change { + case .renamed: + var updated = original; updated.metadata?["title"] = .string("Renamed locally") + model.receiveConversationUpdate(updated, from: model.scope) + case .deleted: model.receiveDeletion(["one"], all: false, from: model.scope) + case .retired: model.invalidate() + case .cancelled: poll.cancel() + } + let expected = model.conversations + await gate.release(); await poll.value + #expect(model.conversations == expected) + } + + @Test func failedOrCancelledRefreshKeepsLastSuccessfulPageAndCursor() async { + let (model, service, _, _, _) = fixture(), gate = ChatTestGate() + let rows = (0..<102).map { libraryConversation(String($0)) } + await service.configure(conversations: rows) + await model.load() + await service.configure(failList: true) + await model.refresh() + #expect(model.conversations == Array(rows.prefix(100)) && model.hasMore && model.issue == .connection) + await service.configure(conversations: [libraryConversation("stale")], listGate: gate) + let refresh = Task { await model.refresh() } + await gate.waitForArrival(); refresh.cancel(); await gate.release(); await refresh.value + #expect(model.conversations == Array(rows.prefix(100)) && model.hasMore && !model.isLoading) + #expect(model.issue == nil) + await service.configure(conversations: rows) + await model.loadMore() + #expect(model.conversations == rows && !model.hasMore) + } + + @Test func unchangedProjectRefreshDoesNotPublishAndKeepsCacheWhileLoading() async { + let (model, service, _, _, _) = fixture(), gate = ChatTestGate() + await model.loadProjects() + let projects = model.projects, changes = LibraryObservationChanges() + _ = withObservationTracking { + model.projects + } onChange: { + changes.record() + } + await service.configureProjects(projects, gate: gate) + let refresh = Task { await model.loadProjects() } + await gate.waitForArrival() + #expect(model.projects == projects) + await gate.release(); await refresh.value + #expect(changes.value == 0 && !model.isLoadingProjects) + } +} + +@MainActor @Suite struct InstructionEditorTests { + @Test func defaultBeyondFirstPageLoadsAndWhitespaceSaveDeletesOnlyThatDefault() async { + let service = LibraryFixture(), scope = AccountScope(apiOrigin: "http://127.0.0.1:3000", principalID: "fixture") + let rows = + (0..<100).map { libraryInstruction(String($0), isDefault: false) } + [ + libraryInstruction("default", prompt: "Original") + ] + await service.configure(instructions: rows) + let editor = InstructionEditor(scope: scope, destination: .account, service: service) + await editor.load() + #expect(editor.text == "Original" && !editor.isDirty) + editor.text = " \n" + #expect(editor.isDirty) + await editor.save() + #expect(await service.instructionChanges == ["delete"]) + #expect(editor.didSave && !editor.isDirty && editor.text.isEmpty) + #expect(await service.instructions.count == 100) + } + @Test func failedLoadDoesNotCreateAnEmptyReplacement() async { + let service = LibraryFixture(), scope = AccountScope(apiOrigin: "http://127.0.0.1:3000", principalID: "fixture") + await service.configure(failList: true) + let editor = InstructionEditor(scope: scope, destination: .account, service: service) + await editor.load(); editor.text = "replacement"; await editor.save() + #expect(!editor.isLoaded && !editor.didSave) + #expect(await service.instructionChanges.isEmpty) + } + @Test func projectEditorUsesExplicitSaveAndNullClearing() async { + let service = LibraryFixture(), scope = AccountScope(apiOrigin: "http://127.0.0.1:3000", principalID: "fixture") + let editor = InstructionEditor(scope: scope, destination: .project("project"), service: service) + await editor.load(); editor.text = "draft only" + #expect(await service.projectInstructions == "project original") + editor.discardChanges(); #expect(editor.text == "project original" && !editor.isDirty) + editor.text = " \n "; await editor.save() + #expect(await service.projectInstructions == nil) + #expect(editor.didSave) + } + @Test func retiredEditorDropsLateLoad() async { + let service = LibraryFixture(), + scope = AccountScope(apiOrigin: "http://127.0.0.1:3000", principalID: "fixture"), gate = ChatTestGate() + await service.configure(instructions: [libraryInstruction("private")], listGate: gate) + let editor = InstructionEditor(scope: scope, destination: .account, service: service) + let loading = Task { await editor.load() } + await gate.waitForArrival(); editor.invalidate(); await gate.release(); await loading.value + #expect(editor.text.isEmpty && !editor.isLoaded) + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Library/LibraryOwnershipTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Library/LibraryOwnershipTests.swift new file mode 100644 index 000000000..7f00c1d3b --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Library/LibraryOwnershipTests.swift @@ -0,0 +1,185 @@ +import Foundation +import MapleSDK +import Testing +@testable import MapleAppCore + +@MainActor @Suite struct LibraryOwnershipTests { + @Test func changedFilterDropsOldPageAndDoesNotClearNewLoadingState() async { + let (model, service, _, _, _) = LibraryModelTests().fixture(), oldRead = ChatTestGate() + await service.configure( + conversations: [libraryConversation("a"), libraryConversation("b", pinned: true)], listGate: oldRead) + let initial = Task { await model.load() } + await oldRead.waitForArrival() + await service.configure(listGate: nil) + await model.load(.pinned) + await oldRead.release(); await initial.value + #expect(model.filter == .pinned) + #expect(model.conversations.map(\.id) == ["b"] && !model.isLoading) + } + + @Test func pageStartedBeforeRenameCannotRestoreOldMetadata() async { + let (model, service, _, _, _) = LibraryModelTests().fixture(), gate = ChatTestGate() + await service.configure(conversations: [libraryConversation("a")]) + await model.load() + await service.configure(listGate: gate) + let reading = Task { await model.load() } + await gate.waitForArrival() + await model.renameConversation(id: "a", title: "New title") + await gate.release(); await reading.value + #expect(!model.conversations.contains { LibraryModel.title($0) == "Title a" }) + await service.configure(listGate: nil) + await model.loadMore() + #expect(model.conversations.map(LibraryModel.title) == ["New title"]) + } + + @Test func readStartedDuringRenameIsInvalidatedWhenMutationSettles() async { + let (model, service, _, _, _) = LibraryModelTests().fixture() + let mutationGate = ChatTestGate(), readGate = ChatTestGate() + await service.configure(conversations: [libraryConversation("a")]) + await model.load() + await service.holdUpdate(mutationGate) + let mutating = Task { await model.renameConversation(id: "a", title: "New title") } + await mutationGate.waitForArrival() + await service.configure(listGate: readGate) + let reading = Task { await model.load() } + await readGate.waitForArrival() + await mutationGate.release(); await mutating.value + await readGate.release(); await reading.value + #expect(!model.conversations.contains { LibraryModel.title($0) == "Title a" }) + await service.configure(listGate: nil) + await model.loadMore() + #expect(model.conversations.map(LibraryModel.title) == ["New title"]) + } + + @Test func accountRetirementDuringMutationCannotPublishMetadataOrCallbacks() async { + let (model, service, _, _, _) = LibraryModelTests().fixture(), gate = ChatTestGate() + await service.configure(conversations: [libraryConversation("a")]) + await model.load(); await service.holdUpdate(gate) + var callbacks = 0 + model.searchText = "Private search query" + model.onConversationUpdated = { _ in callbacks += 1 } + let mutation = Task { await model.setPinned(id: "a", pinned: true) } + await gate.waitForArrival(); model.invalidate() + await gate.release(); await mutation.value + #expect(model.conversations.isEmpty && !model.isMutating) + #expect(model.searchText.isEmpty) + #expect(callbacks == 0) + } + + @Test func repeatedPaginationCursorFailsWithoutDuplicatingRows() async { + let (model, service, _, _, _) = LibraryModelTests().fixture() + await service.configure(conversations: [libraryConversation("a")], repeatedCursor: true) + await model.load(); await model.loadMore() + #expect(model.issue == .invalidResponse && !model.isLoading) + #expect(model.conversations.map(\.id) == ["a"]) + } + + @Test func mixedSectionMovePublishesOnlyAcknowledgedBatchesWithoutLoadingAnotherList() async { + let (model, service, _, _, _) = LibraryModelTests().fixture() + let ids = Set((0..<25).map { String(format: "chat-%02d", $0) }) + await service.configureMove(failedCall: 2) + var acknowledged = Set() + var destinations: [String?] = [] + model.onConversationProjectUpdated = { changed, destination in + acknowledged.formUnion(changed); destinations.append(destination) + } + model.selectedIDs = ids + await model.moveConversations(ids: ids, projectID: "destination") + #expect(model.conversations.isEmpty) + #expect(acknowledged == Set(ids.sorted().prefix(20))) + #expect(destinations == ["destination"]) + #expect(model.selectedIDs == ids.subtracting(acknowledged)) + #expect(model.issue != nil) + + // Clearing the project must be published too; failed batches stay selected for retry. + await service.configureMove() + await model.moveConversations(ids: model.selectedIDs, projectID: nil) + #expect(acknowledged == ids && destinations == ["destination", nil]) + #expect(model.selectedIDs.isEmpty && model.issue == nil) + } + + @Test func retiredMixedSectionMoveCannotPublishMembershipToAnotherAccountGeneration() async { + let (model, service, _, _, _) = LibraryModelTests().fixture(), gate = ChatTestGate() + await service.configureMove(gate: gate) + var callbacks = 0 + model.onConversationProjectUpdated = { _, _ in callbacks += 1 } + model.selectedIDs = ["selected"] + let moving = Task { await model.moveConversations(ids: ["selected"], projectID: "destination") } + await gate.waitForArrival(); model.invalidate() + await gate.release(); #expect(await moving.value == false) + #expect(callbacks == 0 && model.selectedIDs.isEmpty && !model.isMutating) + } + + @Test func projectSearchUsesLoadedChatTitlesButDoesNotSearchUnloadedContent() async { + let (model, service, _, _, _) = LibraryModelTests().fixture() + await service.configure(conversations: [libraryConversation("needle", project: "project")]) + await model.loadProjects() + model.searchText = "needle" + #expect(model.visibleProjects.isEmpty) + await model.load() + #expect(model.visibleProjects.map(\.id) == ["project"]) + model.searchText = " PrOjEcT " + #expect(model.visibleProjects.map(\.id) == ["project"]) + #expect(model.visibleConversations.isEmpty) + } + + @Test func siblingPinAcknowledgementWinsOverPendingRecentRefresh() async { + let (model, service, _, _, _) = LibraryModelTests().fixture(), gate = ChatTestGate() + await service.configure(conversations: [libraryConversation("a")]) + await model.load(.recent) + await service.configure(listGate: gate) + let refresh = Task { await model.refresh() } + await gate.waitForArrival() + model.receiveConversationUpdate(libraryConversation("a", pinned: true), from: model.scope) + await gate.release(); await refresh.value + #expect(model.conversations.isEmpty && model.visibleConversations.isEmpty && !model.isLoading) + } + + @Test func siblingMoveAndDeleteCannotBeUndoneByPendingRefresh() async { + let (model, service, _, _, _) = LibraryModelTests().fixture(), gate = ChatTestGate() + await service.configure(conversations: [libraryConversation("a"), libraryConversation("b")]) + await model.load(.recent); model.selectedIDs = ["a", "b"] + await service.configure(listGate: gate) + let refresh = Task { await model.refresh() } + await gate.waitForArrival() + model.receiveProjectMembership(["a"], projectID: "folder", from: model.scope) + model.receiveDeletion(["b"], all: false, from: model.scope) + await gate.release(); await refresh.value + #expect(model.visibleConversations.isEmpty && model.selectedIDs == ["a"]) + #expect(!model.isLoading) + } + + @Test func previousAccountGenerationCannotBroadcastIntoCurrentCache() async { + let (model, service, _, _, _) = LibraryModelTests().fixture() + await service.configure(conversations: [libraryConversation("a")]) + await model.load(); await model.loadProjects() + let oldOwner = AccountScope(apiOrigin: model.scope.apiOrigin, principalID: model.scope.principalID) + model.receiveConversationUpdate(libraryConversation("other"), from: oldOwner) + model.receiveProjectMembership(["a"], projectID: "foreign", from: oldOwner) + model.receiveDeletion(["a"], all: true, from: oldOwner) + model.receiveProjectDeletion("project", from: oldOwner) + #expect(model.conversations == [libraryConversation("a")] && model.projects.count == 1) + model.invalidate() + model.receiveConversationUpdate(libraryConversation("late"), from: model.scope) + #expect(model.conversations.isEmpty) + } + + @Test func projectAcknowledgementInvalidatesOlderListAndRetirementDropsLateProjects() async { + let (model, service, _, _, _) = LibraryModelTests().fixture(), gate = ChatTestGate() + await model.loadProjects() + await service.configureProjects(model.projects, gate: gate) + let refresh = Task { await model.loadProjects() } + await gate.waitForArrival() + model.receiveProjectUpdate( + ConversationProject(id: "project", object: "project", name: "Renamed", createdAt: 0, updatedAt: 1), + from: model.scope) + await gate.release(); await refresh.value + #expect(model.projects.first?.name == "Renamed" && !model.isLoadingProjects) + let retiredGate = ChatTestGate() + await service.configureProjects(model.projects, gate: retiredGate) + let retiredRead = Task { await model.loadProjects() } + await retiredGate.waitForArrival(); model.invalidate() + await retiredGate.release(); await retiredRead.value + #expect(model.projects.isEmpty && !model.isLoadingProjects) + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Library/LibraryProjectExpansionTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Library/LibraryProjectExpansionTests.swift new file mode 100644 index 000000000..8813b0a18 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Library/LibraryProjectExpansionTests.swift @@ -0,0 +1,470 @@ +import Foundation +import Testing + +@testable import MapleAppCore + +private func expansionScope( + origin: String = "https://expansion.invalid", principal: String = "fixture" +) -> AccountScope { + AccountScope(apiOrigin: origin, principalID: principal) +} + +private func expansionDirectory() -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent("expansion-tests-" + UUID().uuidString) +} + +private func expansionRecord(_ root: URL, scope: AccountScope) throws -> URL { + root.appendingPathComponent(try LibraryProjectExpansionStore.accountName(scope) + ".json") +} + +private func expansionDigest(_ id: String) throws -> String { + try #require(LibraryProjectExpansionStore.projectDigest(id)) +} + +private func writeExpansionBytes(_ bytes: Data, root: URL, scope: AccountScope) throws { + let files = ProtectedChatFiles(root: root), account = try LibraryProjectExpansionStore.accountName(scope) + try files.withDirectory { directory in + try files.publish(bytes, name: account + ".json", account: account, directory: directory) {} + } +} + +@Suite struct LibraryProjectExpansionStoreTests { + @Test func accountAndBackendNamespacesRetainOnlyHashedMetadataAcrossRelaunch() async throws { + let root = expansionDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let store = LibraryProjectExpansionStore(root: root), owner = expansionScope() + let expanded: Set = [try expansionDigest("project-private-id")] + #expect(try await store.activate(owner).isEmpty) + try await store.save(expanded, scope: owner, revision: 1) + try await store.retire(owner) + for other in [expansionScope(origin: "https://other.invalid"), expansionScope(principal: "other")] { + #expect(try await store.activate(other).isEmpty) + try await store.retire(other) + } + let reopened = LibraryProjectExpansionStore(root: root) + #expect(try await reopened.activate(expansionScope()) == expanded) + let file = try expansionRecord(root, scope: owner), bytes = try Data(contentsOf: file) + let object = try #require(JSONSerialization.jsonObject(with: bytes) as? [String: Any]) + #expect(Set(object.keys) == ["version", "expanded"]) + let text = String(decoding: bytes, as: UTF8.self) + #expect(!text.contains("project-private-id") && !text.contains(owner.principalID)) + #expect(!text.contains(owner.apiOrigin) && !file.lastPathComponent.contains(owner.principalID)) + #expect(bytes.count < LibraryProjectExpansionStore.maximumBytes) + let attributes = try FileManager.default.attributesOfItem(atPath: file.path) + #expect((attributes[.posixPermissions] as? NSNumber)?.intValue == 0o600) + #expect( + (try FileManager.default.attributesOfItem(atPath: root.path)[.posixPermissions] as? NSNumber)?.intValue + == 0o700) + for item in [root, file] { + #expect(try item.resourceValues(forKeys: [.isExcludedFromBackupKey]).isExcludedFromBackup == true) + } + #if os(iOS) + #expect(attributes[.protectionKey] as? FileProtectionType == .complete) + #endif + } + + @Test func retiredGenerationCannotWriteOrClearReplacementAndStaleRevisionCannotRewind() async throws { + let root = expansionDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let store = LibraryProjectExpansionStore(root: root), old = expansionScope(), current = expansionScope() + _ = try await store.activate(old) + try await store.retire(old) + _ = try await store.activate(current) + let latest: Set = [try expansionDigest("latest")] + try await store.save(latest, scope: current, revision: 3) + try await store.save([], scope: current, revision: 2) + #expect(try await store.activate(current) == latest) + await #expect(throws: LibraryProjectExpansionIssue.sessionChanged) { + try await store.save([], scope: old, revision: 100) + } + await #expect(throws: LibraryProjectExpansionIssue.sessionChanged) { try await store.activate(old) } + await #expect(throws: LibraryProjectExpansionIssue.sessionChanged) { + try await store.retire(old, removePersisted: true) + } + try await store.retire(current) + await #expect(throws: LibraryProjectExpansionIssue.sessionChanged) { + try await store.retire(old, removePersisted: true) + } + #expect(try await store.activate(expansionScope()) == latest) + } + + @Test func confirmedDeletionIsIdempotentAndClearsOnlyExactAccount() async throws { + let root = expansionDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let store = LibraryProjectExpansionStore(root: root), old = expansionScope() + let current = expansionScope(principal: "different"), retained: Set = [try expansionDigest("kept")] + _ = try await store.activate(old) + try await store.save([try expansionDigest("removed")], scope: old, revision: 1) + try await store.retire(old) + _ = try await store.activate(current) + try await store.save(retained, scope: current, revision: 1) + try await store.retire(old, removePersisted: true) + try await store.retire(old) + #expect(try await store.activate(current) == retained) + try await store.retire(current) + #expect(try await store.activate(expansionScope()).isEmpty) + #expect(try FileManager.default.contentsOfDirectory(atPath: root.path).count == 1) + } + + @Test func corruptFutureAndOversizedRecordsStayUntouched() async throws { + let root = expansionDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let store = LibraryProjectExpansionStore(root: root), owner = expansionScope() + _ = try await store.activate(owner) + let file = try expansionRecord(root, scope: owner), digest = try expansionDigest("duplicate") + let invalid = [ + Data("invalid".utf8), Data(repeating: 0, count: LibraryProjectExpansionStore.maximumBytes + 1), + try JSONSerialization.data(withJSONObject: ["version": 1, "expanded": ["raw-project-id"]]), + try JSONSerialization.data(withJSONObject: ["version": 1, "expanded": [digest, digest]]), + ] + for bytes in invalid { + try writeExpansionBytes(bytes, root: root, scope: owner) + await #expect(throws: LibraryProjectExpansionIssue.invalidRecord) { try await store.activate(owner) } + #expect(try Data(contentsOf: file) == bytes) + } + let future = try JSONSerialization.data(withJSONObject: ["version": 2, "expanded": []]) + try writeExpansionBytes(future, root: root, scope: owner) + await #expect(throws: LibraryProjectExpansionIssue.unsupportedVersion) { try await store.activate(owner) } + #expect(try Data(contentsOf: file) == future) + } + + @Test func lockedDeletionRevokesImmediatelyAndRetriesBeforeReplacementLoad() async throws { + let root = expansionDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let store = LibraryProjectExpansionStore(root: root), old = expansionScope(), current = expansionScope() + _ = try await store.activate(old) + try await store.save([try expansionDigest("removed")], scope: old, revision: 1) + await store.setProtectedDataAvailable(false) + await #expect(throws: LibraryProjectExpansionIssue.protectedDataUnavailable) { + try await store.retire(old, removePersisted: true) + } + await #expect(throws: LibraryProjectExpansionIssue.sessionChanged) { + try await store.save([], scope: old, revision: 2) + } + await #expect(throws: LibraryProjectExpansionIssue.protectedDataUnavailable) { + try await store.activate(current) + } + await store.setProtectedDataAvailable(true) + #expect(try await store.activate(current).isEmpty) + #expect(try FileManager.default.contentsOfDirectory(atPath: root.path).isEmpty) + } + + @Test func failedLockedReadStillAcquiresRetirableGenerationLease() async throws { + let root = expansionDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let store = LibraryProjectExpansionStore(root: root), old = expansionScope(), current = expansionScope() + _ = try await store.activate(old) + try await store.retire(old) + await store.setProtectedDataAvailable(false) + await #expect(throws: LibraryProjectExpansionIssue.protectedDataUnavailable) { + try await store.activate(current) + } + try await store.retire(current) + await #expect(throws: LibraryProjectExpansionIssue.sessionChanged) { try await store.activate(current) } + await #expect(throws: LibraryProjectExpansionIssue.sessionChanged) { + try await store.retire(old, removePersisted: true) + } + await store.setProtectedDataAvailable(true) + #expect(try await store.activate(expansionScope()).isEmpty) + } + + @Test func preparedDeletionSurvivesLockedTerminationAndCleansOnlyMarkedAccount() async throws { + let root = expansionDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let store = LibraryProjectExpansionStore(root: root), owner = expansionScope() + let other = expansionScope(principal: "retained"), retained: Set = [try expansionDigest("retained")] + _ = try await store.activate(other) + try await store.save(retained, scope: other, revision: 1) + try await store.retire(other) + _ = try await store.activate(owner) + try await store.save([try expansionDigest("deleted")], scope: owner, revision: 1) + try await store.prepareRemoval(owner) + // Calling startup cleanup in a live process must not consume the + // current account's marker before its remote request finishes. + try await store.startupCleanup() + #expect(!((try await store.activate(owner)).isEmpty)) + let marker = root.appendingPathComponent(try LibraryProjectExpansionStore.accountName(owner) + ".removal") + #expect(try Data(contentsOf: marker).count < 128) + #expect(try marker.resourceValues(forKeys: [.isExcludedFromBackupKey]).isExcludedFromBackup == true) + await store.setProtectedDataAvailable(false) + await #expect(throws: LibraryProjectExpansionIssue.protectedDataUnavailable) { + try await store.retire(owner, removePersisted: true) + } + let reopened = LibraryProjectExpansionStore(root: root, protectionAvailable: false) + await #expect(throws: LibraryProjectExpansionIssue.protectedDataUnavailable) { + try await reopened.startupCleanup() + } + await reopened.setProtectedDataAvailable(true) + try await reopened.startupCleanup() + #expect(try await reopened.activate(expansionScope()).isEmpty) + #expect(!FileManager.default.fileExists(atPath: marker.path)) + let otherReopened = LibraryProjectExpansionStore(root: root) + #expect(try await otherReopened.activate(other) == retained) + } + + @Test func failedDeletionWhileLockedRetriesCancellationBeforeRestart() async throws { + let root = expansionDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let store = LibraryProjectExpansionStore(root: root), owner = expansionScope() + let retained: Set = [try expansionDigest("retained")] + _ = try await store.activate(owner) + try await store.save(retained, scope: owner, revision: 1) + try await store.prepareRemoval(owner) + await store.setProtectedDataAvailable(false) + await #expect(throws: LibraryProjectExpansionIssue.protectedDataUnavailable) { + try await store.cancelRemoval(owner) + } + await store.setProtectedDataAvailable(true) + try await store.startupCleanup() + try await store.retire(owner) + let reopened = LibraryProjectExpansionStore(root: root) + try await reopened.startupCleanup() + #expect(try await reopened.activate(expansionScope()) == retained) + } + + @Test func uncertainOldMarkerIsConsumedBeforeReplacementIncludingLockedHandoff() async throws { + for locked in [false, true] { + let root = expansionDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let store = LibraryProjectExpansionStore(root: root), old = expansionScope(), current = expansionScope() + _ = try await store.activate(old) + try await store.save([try expansionDigest("old")], scope: old, revision: 1) + try await store.prepareRemoval(old) + try await store.retire(old) + if locked { + await store.setProtectedDataAvailable(false) + await #expect(throws: LibraryProjectExpansionIssue.protectedDataUnavailable) { + try await store.activate(current) + } + await store.setProtectedDataAvailable(true) + try await store.startupCleanup() + } + #expect(try await store.activate(current).isEmpty) + let replacement: Set = [try expansionDigest("replacement")] + try await store.save(replacement, scope: current, revision: 1) + try await store.prepareRemoval(current) + await #expect(throws: LibraryProjectExpansionIssue.sessionChanged) { + try await store.cancelRemoval(old) + } + await #expect(throws: LibraryProjectExpansionIssue.sessionChanged) { + try await store.retire(old, removePersisted: true) + } + // The stale result must not remove the current prepared marker. + let marker = root.appendingPathComponent(try LibraryProjectExpansionStore.accountName(current) + ".removal") + #expect(FileManager.default.fileExists(atPath: marker.path)) + try await store.cancelRemoval(current) + try await store.retire(current) + let reopened = LibraryProjectExpansionStore(root: root) + try await reopened.startupCleanup() + #expect(try await reopened.activate(expansionScope()) == replacement) + } + } + + @Test func invalidProjectIdentifiersAndRecordCountsAreBounded() async throws { + #expect(LibraryProjectExpansionStore.projectDigest("") == nil) + #expect(LibraryProjectExpansionStore.projectDigest(String(repeating: "a", count: 1_025)) == nil) + #expect(LibraryProjectExpansionStore.projectDigest(String(repeating: "a", count: 1_024)) != nil) + let root = expansionDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let store = LibraryProjectExpansionStore(root: root), owner = expansionScope() + _ = try await store.activate(owner) + await #expect(throws: LibraryProjectExpansionIssue.invalidRecord) { + try await store.save(["raw-project-id"], scope: owner, revision: 1) + } + let excess = Set( + (0...LibraryProjectExpansionStore.maximumProjects).map { ProtectedChatFiles.digest(Data("\($0)".utf8)) }) + await #expect(throws: LibraryProjectExpansionIssue.invalidRecord) { + try await store.save(excess, scope: owner, revision: 1) + } + #expect(try FileManager.default.contentsOfDirectory(atPath: root.path).isEmpty) + } +} + +@MainActor @Suite struct LibraryProjectExpansionControllerTests { + @Test func expandAndCollapseRestoreAcrossFreshOwnersAndFreshStore() async throws { + let root = expansionDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let store = LibraryProjectExpansionStore(root: root) + let first = LibraryProjectExpansionController(scope: expansionScope(), store: store) + await first.load() + #expect(first.state == .ready && !first.isExpanded(id: "project-A")) + #expect(first.setExpanded(id: "project-A", expanded: true)) + try await first.retire() + let recreated = LibraryProjectExpansionController(scope: expansionScope(), store: store) + await recreated.load() + #expect(recreated.isExpanded(id: "project-A")) + #expect(recreated.setExpanded(id: "project-A", expanded: false)) + #expect(recreated.setExpanded(id: "project-B", expanded: true)) + try await recreated.retire() + let relaunched = LibraryProjectExpansionController( + scope: expansionScope(), store: LibraryProjectExpansionStore(root: root)) + await relaunched.load() + #expect(!relaunched.isExpanded(id: "project-A") && relaunched.isExpanded(id: "project-B")) + // A missing/partial library page is not a deletion signal. Reading only + // another folder and saving a new choice must retain the absent folder. + #expect(!relaunched.isExpanded(id: "not-in-this-page")) + relaunched.setExpanded(id: "project-C", expanded: true) + try await relaunched.retire() + let later = LibraryProjectExpansionController( + scope: expansionScope(), store: LibraryProjectExpansionStore(root: root)) + await later.load() + #expect(later.isExpanded(id: "project-B") && later.isExpanded(id: "project-C")) + } + + @Test func rapidChoicesDrainOnRetirementAndInvalidatedOwnerCannotMutate() async throws { + let root = expansionDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let store = LibraryProjectExpansionStore(root: root) + let controller = LibraryProjectExpansionController(scope: expansionScope(), store: store) + await controller.load() + controller.setExpanded(id: "A", expanded: true) + controller.setExpanded(id: "B", expanded: true) + controller.setExpanded(id: "A", expanded: false) + controller.invalidate(); controller.invalidate() + #expect(!controller.setExpanded(id: "B", expanded: false)) + try await controller.retire() + #expect(controller.state == .retired && !controller.isExpanded(id: "B")) + #expect(try await store.activate(expansionScope()) == [try expansionDigest("B")]) + } + + @Test func lockedReadKeepsChoicesInMemoryAndMergesOnUnlockWithoutEarlyWrite() async throws { + let root = expansionDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let store = LibraryProjectExpansionStore(root: root), saved = expansionScope() + _ = try await store.activate(saved) + try await store.save( + [try expansionDigest("retained"), try expansionDigest("collapse")], scope: saved, revision: 1) + try await store.retire(saved) + let before = try Data(contentsOf: expansionRecord(root, scope: saved)) + await store.setProtectedDataAvailable(false) + let controller = LibraryProjectExpansionController(scope: expansionScope(), store: store) + await controller.load() + #expect(controller.state == .failed(.protectedDataUnavailable)) + controller.setExpanded(id: "new", expanded: true) + controller.setExpanded(id: "collapse", expanded: false) + await controller.flush() + #expect(controller.isExpanded(id: "new")) + #expect(try Data(contentsOf: expansionRecord(root, scope: saved)) == before) + await store.setProtectedDataAvailable(true) + await controller.load(); await controller.flush() + #expect(controller.state == .ready && controller.isExpanded(id: "retained")) + #expect(controller.isExpanded(id: "new") && !controller.isExpanded(id: "collapse")) + #expect( + try await store.activate(controller.scope) == [try expansionDigest("retained"), try expansionDigest("new")]) + } + + @Test func corruptReadNeverOverwritesUnknownRecordUntilSuccessfulRetry() async throws { + let root = expansionDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let store = LibraryProjectExpansionStore(root: root), owner = expansionScope() + _ = try await store.activate(owner) + let file = try expansionRecord(root, scope: owner), corrupt = Data("future-or-corrupt".utf8) + try writeExpansionBytes(corrupt, root: root, scope: owner) + let controller = LibraryProjectExpansionController(scope: owner, store: store) + await controller.load() + #expect(controller.state == .failed(.invalidRecord)) + controller.setExpanded(id: "local", expanded: true) + controller.retrySave(); await controller.flush() + #expect(try Data(contentsOf: file) == corrupt) + let retained = try expansionDigest("retained") + let repaired = try JSONSerialization.data(withJSONObject: ["version": 1, "expanded": [retained]]) + try writeExpansionBytes(repaired, root: root, scope: owner) + await controller.load(); await controller.flush() + #expect(controller.state == .ready && controller.isExpanded(id: "local")) + #expect(controller.isExpanded(id: "retained")) + } + + @Test func lockedWriteRetainsPreviousRecordAndRetryPublishesLatestChoice() async throws { + let root = expansionDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let store = LibraryProjectExpansionStore(root: root) + let controller = LibraryProjectExpansionController(scope: expansionScope(), store: store) + await controller.load() + controller.setExpanded(id: "previous", expanded: true) + await controller.flush() + let file = try expansionRecord(root, scope: controller.scope), before = try Data(contentsOf: file) + await store.setProtectedDataAvailable(false) + controller.setExpanded(id: "previous", expanded: false) + controller.setExpanded(id: "latest", expanded: true) + await controller.flush() + #expect(controller.saveIssue == .protectedDataUnavailable && controller.isExpanded(id: "latest")) + #expect(try Data(contentsOf: file) == before) + await store.setProtectedDataAvailable(true) + controller.retrySave(); await controller.flush() + #expect(controller.saveIssue == nil) + #expect(try await store.activate(controller.scope) == [try expansionDigest("latest")]) + } + + @Test func failedDeletionCancelsMarkerAndPreservesChoicesAcrossRestart() async throws { + let root = expansionDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let store = LibraryProjectExpansionStore(root: root) + let controller = LibraryProjectExpansionController(scope: expansionScope(), store: store) + await controller.load() + controller.setExpanded(id: "retained", expanded: true) + try await controller.prepareAccountDeletion() + try await controller.cancelAccountDeletion() + try await controller.retire() + let reopened = LibraryProjectExpansionStore(root: root) + try await reopened.startupCleanup() + #expect(try await reopened.activate(expansionScope()) == [try expansionDigest("retained")]) + } + + @Test func lateLoadCannotPublishAfterRetirement() async throws { + let store = ControlledExpansionStore(), + controller = LibraryProjectExpansionController( + scope: expansionScope(), store: store) + let loading = Task { await controller.load() } + await store.waitForLoad() + controller.setExpanded(id: "local", expanded: true) + try await controller.retire() + await store.finishLoad([try expansionDigest("late")]) + await loading.value + #expect(controller.state == .retired) + #expect(!controller.isExpanded(id: "late") && !controller.isExpanded(id: "local")) + #expect(await store.saved == nil) + } + + @Test func pendingChoicesAndMergedRecordAreBoundedWithoutEvictingStoredChoices() async throws { + let store = ControlledExpansionStore(), + controller = LibraryProjectExpansionController( + scope: expansionScope(), store: store) + let loading = Task { await controller.load() } + await store.waitForLoad() + for index in 0..? + private var continuation: CheckedContinuation, Never>? + private var started = false + func activate(_ scope: AccountScope) async throws -> Set { + started = true + return await withCheckedContinuation { continuation = $0 } + } + func save(_ digests: Set, scope: AccountScope, revision: UInt64) { saved = digests } + func prepareRemoval(_ scope: AccountScope) {} + func cancelRemoval(_ scope: AccountScope) {} + func retire(_ scope: AccountScope, removePersisted: Bool) {} + func waitForLoad() async { while !started { await Task.yield() } } + func finishLoad(_ digests: Set) { continuation?.resume(returning: digests); continuation = nil } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Library/LibraryRecentsPollingTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Library/LibraryRecentsPollingTests.swift new file mode 100644 index 000000000..4d8e976a4 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Library/LibraryRecentsPollingTests.swift @@ -0,0 +1,60 @@ +import Foundation +import Testing +@testable import MapleAppCore + +actor LibraryRecentsSleepRecorder { + private(set) var delays: [Duration] = [] + let gates: [ChatTestGate] + let fails: Bool + + init(gates: [ChatTestGate] = [], fails: Bool = false) { + self.gates = gates; self.fails = fails + } + + func sleep(_ duration: Duration) async throws { + delays.append(duration) + if delays.count <= gates.count { await gates[delays.count - 1].hold() } + if fails { throw URLError(.timedOut) } + } +} + +@MainActor @Suite struct LibraryRecentsPollingTests { + @Test func recentsRefreshesRemainSixtySecondsApart() async { + let clock = LibraryRecentsSleepRecorder() + var refreshes = 0 + await LibraryRecentsPolling.run( + shouldContinue: { refreshes < 3 }, refresh: { refreshes += 1 }, + sleep: { try await clock.sleep($0) }) + #expect(refreshes == 3) + #expect(await clock.delays == [.seconds(60), .seconds(60), .seconds(60)]) + } + + @Test(arguments: [true, false]) + func cancellationOrOwnershipLossDuringDelayPreventsRead(_ cancelled: Bool) async { + let gate = ChatTestGate(), clock = LibraryRecentsSleepRecorder(gates: [gate]) + var owned = true, refreshes = 0 + let task = Task { + await LibraryRecentsPolling.run( + shouldContinue: { owned }, refresh: { refreshes += 1 }, + sleep: { try await clock.sleep($0) }) + } + await gate.waitForArrival() + if cancelled { task.cancel() } else { owned = false } + await gate.release(); await task.value + #expect(refreshes == 0) + #expect(await clock.delays == [.seconds(60)]) + } + + @Test func hiddenMenuAndFailedSleepNeverFetch() async { + let clock = LibraryRecentsSleepRecorder(fails: true) + var refreshes = 0 + await LibraryRecentsPolling.run( + shouldContinue: { false }, refresh: { refreshes += 1 }, + sleep: { try await clock.sleep($0) }) + #expect(await clock.delays.isEmpty && refreshes == 0) + await LibraryRecentsPolling.run( + shouldContinue: { true }, refresh: { refreshes += 1 }, + sleep: { try await clock.sleep($0) }) + #expect(await clock.delays == [.seconds(60)] && refreshes == 0) + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Library/LibraryRelocationTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Library/LibraryRelocationTests.swift new file mode 100644 index 000000000..5ed5818cb --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Library/LibraryRelocationTests.swift @@ -0,0 +1,95 @@ +import MapleSDK +import Testing +@testable import MapleAppCore + +@MainActor @Suite struct LibraryRelocationTests { + @Test func projectAndRecentDestinationsUnpinWithOneAcknowledgedUpdateEach() async { + let (model, service, _, _, _) = LibraryModelTests().fixture() + let original = libraryConversation("chat", project: "original", pinned: true) + await service.configure(conversations: [original]); await model.load() + var published: [Conversation] = [] + model.onConversationUpdated = { published.append($0) } + model.selectedIDs = ["chat"] + await model.relocateConversation(id: "chat", to: .project("destination"), from: model.scope) + #expect(model.issue == nil && model.selectedIDs.isEmpty) + #expect(await service.conversationUpdateIDs == ["chat"]) + #expect(published.count == 1 && published[0].projectId == "destination" && !published[0].pinned) + #expect(published[0].metadata == original.metadata) + await model.relocateConversation(id: "chat", to: .recent, from: model.scope) + #expect(await service.conversationUpdateIDs == ["chat", "chat"]) + #expect(published.count == 2 && published[1].projectId == nil && !published[1].pinned) + #expect(model.conversations == [published[1]]) + } + + @Test func pinningRetainsProjectAndPublishesToSiblingRecentAndProjectCaches() async { + let (model, service, deletions, _, _) = LibraryModelTests().fixture() + let project = LibraryModel(scope: model.scope, service: service, deletions: deletions) + let pinned = LibraryModel(scope: model.scope, service: service, deletions: deletions) + await service.configure(conversations: [libraryConversation("chat", project: "folder")]) + await model.load(.all); await project.load(.project("folder")); await pinned.load(.pinned) + model.onConversationUpdated = { + project.receiveConversationUpdate($0, from: model.scope) + pinned.receiveConversationUpdate($0, from: model.scope) + } + await model.relocateConversation(id: "chat", to: .pinned, from: model.scope) + #expect(await service.conversationUpdateIDs == ["chat"]) + #expect(pinned.visibleConversations.map(\.id) == ["chat"]) + #expect(pinned.conversations.first?.projectId == "folder") + #expect(project.conversations.first?.pinned == true) + await model.relocateConversation(id: "chat", to: .recent, from: model.scope) + #expect(project.visibleConversations.isEmpty && pinned.visibleConversations.isEmpty) + #expect(model.conversations.first?.projectId == nil && model.conversations.first?.pinned == false) + } + + @Test func previousAccountGenerationAndRetiredSourceCannotStartRelocation() async { + let (model, service, _, _, _) = LibraryModelTests().fixture() + await service.configure(conversations: [libraryConversation("chat")]); await model.load() + let previous = AccountScope(apiOrigin: model.scope.apiOrigin, principalID: model.scope.principalID) + await model.relocateConversation(id: "chat", to: .pinned, from: previous) + #expect(await service.conversationUpdateIDs.isEmpty) + #expect(model.conversations.first?.pinned == false) + model.invalidate() + await model.relocateConversation(id: "chat", to: .project("folder"), from: model.scope) + #expect(await service.conversationUpdateIDs.isEmpty && model.conversations.isEmpty) + #expect(model.issue == .retired) + } + + @Test func retirementDuringDropCannotPublishIntoTheNextGeneration() async { + let (model, service, _, _, _) = LibraryModelTests().fixture(), gate = ChatTestGate() + await service.configure(conversations: [libraryConversation("chat")]); await model.load() + var callbacks = 0 + model.onConversationUpdated = { _ in callbacks += 1 } + await service.holdUpdate(gate) + let dropping = Task { + await model.relocateConversation(id: "chat", to: .project("folder"), from: model.scope) + } + await gate.waitForArrival(); model.invalidate(); await gate.release(); await dropping.value + #expect(callbacks == 0 && model.conversations.isEmpty && !model.isMutating) + } + + @Test func deletionFenceAndInvalidDestinationDoNotSendUpdates() async throws { + let (model, service, deletion, _, _) = LibraryModelTests().fixture() + await service.configure(conversations: [libraryConversation("chat")]); await model.load() + let fence = try deletion.begin(.all) + await model.relocateConversation(id: "chat", to: .recent, from: model.scope) + #expect(model.issue == .busy) + #expect(await service.conversationUpdateIDs.isEmpty) + deletion.release(fence) + await model.relocateConversation(id: "chat", to: .project(" "), from: model.scope) + #expect(model.issue == .invalidInput) + #expect(await service.conversationUpdateIDs.isEmpty) + } + + @Test func malformedAcknowledgementDoesNotPublishOrRemoveSelection() async { + let (model, service, _, _, _) = LibraryModelTests().fixture() + let original = libraryConversation("chat") + await service.configure(conversations: [original]); await model.load() + await service.returnMalformedConversationUpdate() + var callbacks = 0 + model.onConversationUpdated = { _ in callbacks += 1 } + model.selectedIDs = ["chat"] + await model.relocateConversation(id: "chat", to: .pinned, from: model.scope) + #expect(model.issue == .invalidResponse && callbacks == 0) + #expect(model.conversations == [original] && model.selectedIDs == ["chat"]) + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Preferences/PreferencesTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Preferences/PreferencesTests.swift new file mode 100644 index 000000000..95a276d67 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Preferences/PreferencesTests.swift @@ -0,0 +1,342 @@ +import Foundation +import Testing +@testable import MapleAppCore + +@Suite struct PreferencesStoreTests { + private func scope(origin: String = "https://preferences.invalid", principal: String = "fixture") -> AccountScope { + AccountScope(apiOrigin: origin, principalID: principal) + } + + @Test func defaultsAreNativeContractAndDoNotPersistAutoplay() throws { + let value = LocalPreferences() + #expect(value.theme == .system && value.chatFont == .manrope) + #expect(value.selectedModelID == "auto:quick" && value.webSearchPreferred) + #expect(value.thinkingLevel == .default) + #expect(try JSONDecoder().decode(LocalPreferences.self, from: JSONEncoder().encode(value)).webSearchPreferred) + #expect(value.voice == .casualFemale && value.playbackSpeed.rawValue == 1.2) + #expect(value.sidebarIsOpen) + #expect(MaplePlaybackSpeed.allCases.map(\.rawValue) == [1, 1.2, 1.25, 1.5, 2]) + #expect( + Set(MapleVoice.allCases.map(\.rawValue)) + == Set([ + "neutral_female", "neutral_male", "casual_female", "casual_male", "cheerful_female", "ar_male", + "de_female", "de_male", "es_female", "es_male", "fr_female", "fr_male", "hi_female", "hi_male", + "it_female", "it_male", "nl_female", "nl_male", "pt_female", "pt_male", + ])) + let object = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(value)) as? [String: Any]) + #expect( + Set(object.keys) + == Set([ + "theme", "chatFont", "selectedModelID", "webSearchPreferred", "voice", "playbackSpeed", + "sidebarIsOpen", "chatThinkingLevel", + ])) + } + + @Test(arguments: [1.0, 1.25, 1.5, 2.0]) + func legacyRecordDefaultsSidebarOpenAndPreservesSelectedPlaybackRate(rate: Double) async throws { + let suite = "preferences-tests.\(UUID().uuidString)" + defer { UserDefaults.standard.removePersistentDomain(forName: suite) } + let defaults = try #require(UserDefaults(suiteName: suite)), owner = scope() + let legacy: [String: Any] = [ + "theme": "dark", "chatFont": "serif", "selectedModelID": "auto:powerful", "webSearchPreferred": false, + "voice": "fr_male", "playbackSpeed": rate, + ] + let data = try JSONSerialization.data(withJSONObject: ["version": 1, "preferences": legacy]) + let key = try PreferencesStore.key(owner) + defaults.set(data, forKey: key) + let store = PreferencesStore(suiteName: suite) + var migrated = try await store.activate(owner) + #expect(migrated.sidebarIsOpen && migrated.playbackSpeed.rawValue == rate) + #expect(migrated.theme == .dark && migrated.voice == .frenchMale) + #expect(!migrated.webSearchPreferred) + #expect(defaults.data(forKey: key) == data) + migrated.sidebarIsOpen = false + try await store.save(migrated, scope: owner, revision: 1) + try await store.retire(owner) + let restarted = PreferencesStore(suiteName: suite) + #expect(try await restarted.activate(scope()) == migrated) + } + + @Test(arguments: [false, true]) + func legacyMissingOrNullWebSearchDefaultsOnAndPersistsWithoutChangingOtherPreferences(null: Bool) async throws { + let suite = "preferences-tests.\(UUID().uuidString)" + defer { UserDefaults.standard.removePersistentDomain(forName: suite) } + let defaults = try #require(UserDefaults(suiteName: suite)), owner = scope() + var legacy: [String: Any] = [ + "theme": "dark", "chatFont": "serif", "selectedModelID": "auto:powerful", + "voice": "fr_male", "playbackSpeed": 1.5, "sidebarIsOpen": false, + ] + if null { legacy["webSearchPreferred"] = NSNull() } + let data = try JSONSerialization.data(withJSONObject: ["version": 1, "preferences": legacy]) + let key = try PreferencesStore.key(owner) + defaults.set(data, forKey: key) + let store = PreferencesStore(suiteName: suite) + let loaded = try await store.activate(owner) + #expect(loaded.webSearchPreferred) + #expect(loaded.theme == .dark && loaded.chatFont == .serif && loaded.selectedModelID == "auto:powerful") + #expect(loaded.voice == .frenchMale && loaded.playbackSpeed == .fast && !loaded.sidebarIsOpen) + #expect(defaults.data(forKey: key) == data) + try await store.save(loaded, scope: owner, revision: 1) + try await store.retire(owner) + let restarted = PreferencesStore(suiteName: suite) + #expect(try await restarted.activate(scope()) == loaded) + } + + @Test func sidebarChoiceAndNewPlaybackRateRoundTrip() throws { + let choice = LocalPreferences(sidebarIsOpen: false) + let decoded = try JSONDecoder().decode(LocalPreferences.self, from: JSONEncoder().encode(choice)) + #expect(decoded == choice) + #expect(!decoded.sidebarIsOpen && decoded.playbackSpeed.rawValue == 1.2) + } + + @Test(arguments: ["missing", "null", "obsolete"]) + func oldThinkingChoiceDefaultsWithoutOverwritingTheOriginalRecord(kind: String) async throws { + let suite = "preferences-tests.\(UUID().uuidString)" + defer { UserDefaults.standard.removePersistentDomain(forName: suite) } + let defaults = try #require(UserDefaults(suiteName: suite)), owner = scope() + var record = try #require( + JSONSerialization.jsonObject(with: JSONEncoder().encode(LocalPreferences(theme: .dark))) as? [String: Any]) + record.removeValue(forKey: "chatThinkingLevel") + if kind == "null" { record["chatThinkingLevel"] = NSNull() } + if kind == "obsolete" { record["chatThinkingLevel"] = "obsolete-effort" } + let data = try JSONSerialization.data(withJSONObject: ["version": 1, "preferences": record]) + let key = try PreferencesStore.key(owner) + defaults.set(data, forKey: key) + let store = PreferencesStore(suiteName: suite) + let loaded = try await store.activate(owner) + #expect(loaded.thinkingLevel == .default && loaded.theme == .dark) + #expect(defaults.data(forKey: key) == data) + var choice = loaded; choice.thinkingLevel = .xhigh + try await store.save(choice, scope: owner, revision: 1) + try await store.retire(owner) + let restarted = PreferencesStore(suiteName: suite) + #expect(try await restarted.activate(scope()) == choice) + let stored = try #require(defaults.data(forKey: key)) + let object = try #require(JSONSerialization.jsonObject(with: stored) as? [String: Any]) + let preferences = try #require(object["preferences"] as? [String: Any]) + #expect(preferences["chatThinkingLevel"] as? String == "xhigh") + #expect(preferences["thinkingLevel"] == nil) + } + + @Test func originAndPrincipalAreIsolatedAndLogoutRetainsChoicesAcrossRestart() async throws { + let suite = "preferences-tests.\(UUID().uuidString)" + defer { UserDefaults.standard.removePersistentDomain(forName: suite) } + let store = PreferencesStore(suiteName: suite), owner = scope() + let choice = LocalPreferences( + theme: .dark, chatFont: .serif, selectedModelID: "auto:powerful", webSearchPreferred: false, + voice: .frenchMale, playbackSpeed: .fast) + #expect(try await store.activate(owner) == LocalPreferences()) + try await store.save(choice, scope: owner, revision: 1) + try await store.retire(owner) + let otherOrigin = scope(origin: "https://other.invalid") + #expect(try await store.activate(otherOrigin) == LocalPreferences()) + try await store.retire(otherOrigin) + let otherPrincipal = scope(principal: "different") + #expect(try await store.activate(otherPrincipal) == LocalPreferences()) + try await store.retire(otherPrincipal) + let restarted = PreferencesStore(suiteName: suite) + #expect(try await restarted.activate(scope()) == choice) + let key = try PreferencesStore.key(owner) + #expect(!key.contains(owner.principalID) && !key.contains(owner.apiOrigin)) + let bytes = try #require(UserDefaults(suiteName: suite)?.data(forKey: key)) + #expect(bytes.count < 4_096) + #expect(!String(decoding: bytes, as: UTF8.self).contains(owner.apiOrigin)) + } + + @Test func lateWritesAndRetireCannotChangeReplacementGeneration() async throws { + let suite = "preferences-tests.\(UUID().uuidString)" + defer { UserDefaults.standard.removePersistentDomain(forName: suite) } + let store = PreferencesStore(suiteName: suite), old = scope(), new = scope() + _ = try await store.activate(old) + try await store.retire(old) + _ = try await store.activate(new) + let choice = LocalPreferences(theme: .light) + try await store.save(choice, scope: new, revision: 1) + await #expect(throws: PreferencesIssue.sessionChanged) { + try await store.save(.init(theme: .dark), scope: old, revision: 9) + } + await #expect(throws: PreferencesIssue.sessionChanged) { try await store.activate(old) } + await #expect(throws: PreferencesIssue.sessionChanged) { try await store.retire(old, removePersisted: true) } + try await store.retire(new) + await #expect(throws: PreferencesIssue.sessionChanged) { try await store.retire(old, removePersisted: true) } + #expect(try await store.activate(scope()) == choice) + } + + @Test func staleRevisionCannotUndoLatestChoiceAndDeletionRemovesOnlyOwner() async throws { + let suite = "preferences-tests.\(UUID().uuidString)" + defer { UserDefaults.standard.removePersistentDomain(forName: suite) } + let store = PreferencesStore(suiteName: suite), owner = scope(), other = scope(principal: "other") + _ = try await store.activate(other) + try await store.save(.init(theme: .light), scope: other, revision: 1) + try await store.retire(other) + _ = try await store.activate(owner) + try await store.save(.init(theme: .dark), scope: owner, revision: 3) + try await store.save(.init(theme: .light), scope: owner, revision: 2) + #expect(try await store.activate(owner).theme == .dark) + try await store.retire(owner) + try await store.retire(owner, removePersisted: true) + #expect(try await store.activate(scope()) == LocalPreferences()) + let restarted = PreferencesStore(suiteName: suite) + #expect(try await restarted.activate(scope(principal: "other")).theme == .light) + } + + @Test func corruptAndFutureRecordsFailWithoutBeingOverwritten() async throws { + let suite = "preferences-tests.\(UUID().uuidString)" + defer { UserDefaults.standard.removePersistentDomain(forName: suite) } + let defaults = try #require(UserDefaults(suiteName: suite)), owner = scope() + let key = try PreferencesStore.key(owner), store = PreferencesStore(suiteName: suite) + for data in [Data("invalid".utf8), Data(repeating: 0, count: 4_097)] { + defaults.set(data, forKey: key) + await #expect(throws: PreferencesIssue.invalidRecord) { try await store.activate(owner) } + #expect(defaults.data(forKey: key) == data) + } + var record = try #require( + JSONSerialization.jsonObject(with: JSONEncoder().encode(LocalPreferences())) as? [String: Any]) + let future = try JSONSerialization.data(withJSONObject: ["version": 2, "preferences": record]) + defaults.set(future, forKey: key) + await #expect(throws: PreferencesIssue.unsupportedVersion) { try await store.activate(owner) } + #expect(defaults.data(forKey: key) == future) + record["voice"] = "not-a-provider-voice" + defaults.set(try JSONSerialization.data(withJSONObject: ["version": 1, "preferences": record]), forKey: key) + await #expect(throws: PreferencesIssue.invalidRecord) { try await store.activate(owner) } + record["voice"] = "casual_female" + for invalidSidebar in [NSNull(), "false"] as [Any] { + record["sidebarIsOpen"] = invalidSidebar + defaults.set( + try JSONSerialization.data(withJSONObject: ["version": 1, "preferences": record]), forKey: key) + await #expect(throws: PreferencesIssue.invalidRecord) { try await store.activate(owner) } + } + record["sidebarIsOpen"] = true; record["webSearchPreferred"] = "false" + let invalidWebSearch = try JSONSerialization.data(withJSONObject: ["version": 1, "preferences": record]) + defaults.set(invalidWebSearch, forKey: key) + await #expect(throws: PreferencesIssue.invalidRecord) { try await store.activate(owner) } + #expect(defaults.data(forKey: key) == invalidWebSearch) + record["webSearchPreferred"] = true; record["chatThinkingLevel"] = 7 + let invalidThinking = try JSONSerialization.data(withJSONObject: ["version": 1, "preferences": record]) + defaults.set(invalidThinking, forKey: key) + await #expect(throws: PreferencesIssue.invalidRecord) { try await store.activate(owner) } + #expect(defaults.data(forKey: key) == invalidThinking) + } + + @Test func confirmedOldAccountDeletionDoesNotDisturbAnotherSignedInAccount() async throws { + let suite = "preferences-tests.\(UUID().uuidString)" + defer { UserDefaults.standard.removePersistentDomain(forName: suite) } + let store = PreferencesStore(suiteName: suite), old = scope(), current = scope(principal: "other") + _ = try await store.activate(old) + try await store.save(.init(theme: .dark), scope: old, revision: 1) + try await store.retire(old) + _ = try await store.activate(current) + try await store.save(.init(theme: .light), scope: current, revision: 1) + try await store.retire(old, removePersisted: true) + #expect(try await store.activate(current).theme == .light) + try await store.save(.init(theme: .light, voice: .germanMale), scope: current, revision: 2) + let restarted = PreferencesStore(suiteName: suite) + #expect(try await restarted.activate(scope()) == LocalPreferences()) + } + + @Test func modelIdentifierIsBoundedAndNotArbitraryText() { + #expect(LocalPreferences.isValidModelID("vendor/model-1.2:auto")) + #expect(LocalPreferences.isValidModelID(String(repeating: "a", count: 256))) + for bad in ["", "model name", "\nmodel", String(repeating: "a", count: 257), "https://user@host?token=secret"] { + #expect(!LocalPreferences.isValidModelID(bad)) + } + } +} + +@MainActor @Suite struct PreferencesControllerTests { + @Test func rapidChoicesAndRetirementDrainTheLatestWrite() async throws { + let suite = "preferences-tests.\(UUID().uuidString)" + defer { UserDefaults.standard.removePersistentDomain(forName: suite) } + let scope = AccountScope(apiOrigin: "https://preferences.invalid", principalID: "fixture") + let store = PreferencesStore(suiteName: suite), controller = PreferencesController(scope: scope, store: store) + #expect(!controller.setTheme(.dark)) + #expect(!controller.setThinkingLevel(.high)) + await controller.load() + #expect(controller.setTheme(.dark)) + #expect(controller.setPlaybackSpeed(.double)) + #expect(controller.setVoice(.italianFemale)) + #expect(controller.setWebSearchPreferred(false)) + #expect(controller.setSidebarIsOpen(false)) + #expect(controller.setThinkingLevel(.low)) + #expect(controller.setThinkingLevel(.high)) + try await controller.retire() + #expect(controller.state == .retired && !controller.setTheme(.light)) + #expect(!controller.setSidebarIsOpen(true)) + #expect(!controller.setThinkingLevel(.none)) + let replacement = AccountScope(apiOrigin: scope.apiOrigin, principalID: scope.principalID) + let retained = try await store.activate(replacement) + #expect(retained.theme == .dark && retained.playbackSpeed == .double) + #expect(retained.voice == .italianFemale && !retained.webSearchPreferred) + #expect(!retained.sidebarIsOpen) + #expect(retained.thinkingLevel == .high) + } + + @Test func lateLoadCannotPublishAfterRetirement() async throws { + let store = ControlledPreferencesStore(gateLoad: true) + let controller = PreferencesController(scope: accountScope(), store: store) + var publications = 0 + controller.onChange = { _ in publications += 1 } + let loading = Task { await controller.load() } + await store.waitForLoad() + try await controller.retire() + await store.finishLoad(.init(theme: .dark)) + await loading.value + #expect(controller.state == .retired && controller.value == LocalPreferences()) + #expect(publications == 0) + } + + @Test func saveFailureRetainsChoiceAndRetryDoesNotLeakError() async throws { + let store = ControlledPreferencesStore(failSave: true) + let controller = PreferencesController(scope: accountScope(), store: store) + await controller.load() + controller.setVoice(.hindiMale) + await controller.flush() + #expect(controller.saveIssue == .unavailable && controller.value.voice == .hindiMale) + await store.allowSave() + controller.retrySave() + await controller.flush() + #expect(controller.saveIssue == nil) + #expect(await store.saved?.voice == .hindiMale) + #expect(!controller.setSelectedModelID("not a model")) + } + + @Test func loadFailureCanRetryWithoutSavingDefaultsOverUnknownRecord() async { + let store = ControlledPreferencesStore(failLoad: true) + let controller = PreferencesController(scope: accountScope(), store: store) + await controller.load() + #expect(controller.state == .failed(.unsupportedVersion)) + #expect(!controller.setWebSearchPreferred(false)) + #expect(await store.saved == nil) + await store.allowLoad() + await controller.load() + #expect(controller.state == .ready) + } +} + +private actor ControlledPreferencesStore: PreferencesStoring { + struct Failure: Error {} + var saved: LocalPreferences? + private var failSave: Bool + private var failLoad: Bool + private let gateLoad: Bool + private var continuation: CheckedContinuation? + private var started = false + init(gateLoad: Bool = false, failSave: Bool = false, failLoad: Bool = false) { + self.gateLoad = gateLoad; self.failSave = failSave; self.failLoad = failLoad + } + func activate(_ scope: AccountScope) async throws -> LocalPreferences { + started = true + if failLoad { throw PreferencesIssue.unsupportedVersion } + if gateLoad { return await withCheckedContinuation { continuation = $0 } } + return .init() + } + func save(_ value: LocalPreferences, scope: AccountScope, revision: UInt64) throws { + if failSave { throw Failure() } + saved = value + } + func retire(_ scope: AccountScope, removePersisted: Bool) {} + func waitForLoad() async { while !started { await Task.yield() } } + func finishLoad(_ value: LocalPreferences) { continuation?.resume(returning: value); continuation = nil } + func allowSave() { failSave = false } + func allowLoad() { failLoad = false } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/SessionControllerTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/SessionControllerTests.swift new file mode 100644 index 000000000..d65362caa --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/SessionControllerTests.swift @@ -0,0 +1,258 @@ +import Foundation +import MapleSDK +import Testing +@testable import MapleAppCore + +private actor SessionFixture: SessionServing { + struct SignupRequest: Equatable, Sendable { let email: String; let password: String; let name: String? } + let principal = "test-principal" + var current: SessionIdentity? + var failUser = false + var holdUser = false + var requested = false + var userWaiter: CheckedContinuation? + var entryWaiter: CheckedContinuation? + var loginCount = 0 + var signupRequests: [SignupRequest] = [] + var verified = false + init(signedIn: Bool = true) { + if signedIn { + current = SessionIdentity(origin: "http://127.0.0.1:1234", principal: principal, generation: UUID()) + } + } + func identity() -> SessionIdentity? { current } + func user() async throws -> MapleUser { + requested = true; entryWaiter?.resume(); entryWaiter = nil + if holdUser { await withCheckedContinuation { userWaiter = $0 } } + if failUser { throw URLError(.notConnectedToInternet) } + return MapleUser( + id: principal, email: "fixture@example.test", emailVerified: verified, + loginMethod: "email", createdAt: "", updatedAt: "") + } + func configure(fail: Bool = false, hold: Bool = false) { failUser = fail; holdUser = hold; requested = false } + func setVerified() { verified = true } + func awaitUserRequest() async { + if !requested { await withCheckedContinuation { entryWaiter = $0 } } + } + func releaseUser() { userWaiter?.resume(); userWaiter = nil; holdUser = false } + func signIn(email: String, password: String) { + loginCount += 1 + current = SessionIdentity(origin: "http://127.0.0.1:1234", principal: principal, generation: UUID()) + } + func signInGuest(id: String, password: String) { signIn(email: "", password: password) } + func signUp(email: String, password: String, name: String?) { + signupRequests.append(.init(email: email, password: password, name: name)) + signIn(email: email, password: password) + } + func signOut() { current = nil } +} + +private actor LifecycleFixture: AccountDataLifecycle { + var activations: [AccountScope] = [] + var retirements: [AccountScope] = [] + var clears = 0 + var failure = false + func activate(_ scope: AccountScope) { activations.append(scope) } + func retire(_ scope: AccountScope) throws { + if failure { throw ChatStorageError.cleanupRequired } + retirements.append(scope) + } + func clearSignedOutData() throws { + if failure { throw ChatStorageError.cleanupRequired } + clears += 1 + } + func setFailure(_ value: Bool) { failure = value } +} + +@MainActor private final class RetirementFailureControl { var shouldFail = true } + +enum SignupPasswordSample: String, Sendable { + case empty, sevenASCII, sixUTF16, aboveByteLimit, eightASCII, eightUTF16, maximumBytes + + var value: String { + switch self { + case .empty: "" + case .sevenASCII: "seven77" + case .sixUTF16: "😀😀😀" + case .aboveByteLimit: String(repeating: "a", count: 16 * 1024 + 1) + case .eightASCII: "eight888" + case .eightUTF16: "😀😀😀😀" + case .maximumBytes: String(repeating: "a", count: 16 * 1024) + } + } +} + +@MainActor struct SessionControllerTests { + @Test(arguments: [SignupPasswordSample.empty, .sevenASCII, .sixUTF16, .aboveByteLimit]) + func signupRejectsInvalidPasswordWithoutSendingIt(sample: SignupPasswordSample) async { + let service = SessionFixture(signedIn: false), storage = LifecycleFixture() + let controller = SessionController(service: service, storage: storage) + await controller.restore() + await controller.signUp(email: "fixture@example.test", password: sample.value) + #expect(controller.phase == .signedOut && controller.issue == .invalidInput) + #expect(await service.signupRequests.isEmpty) + #expect(await storage.activations.isEmpty) + #expect(await service.identity() == nil) + } + + @Test(arguments: [SignupPasswordSample.eightASCII, .eightUTF16, .maximumBytes]) + func signupUsesResearchNameAndPasswordUTF16Contract(sample: SignupPasswordSample) async { + let service = SessionFixture(signedIn: false), storage = LifecycleFixture() + let controller = SessionController(service: service, storage: storage) + await controller.restore() + await controller.signUp(email: "fixture@example.test", password: sample.value) + #expect(controller.phase == .signedIn && controller.issue == nil) + #expect( + await service.signupRequests + == [.init(email: "fixture@example.test", password: sample.value, name: "ANON")]) + #expect(await storage.activations.count == 1) + } + + @Test func verificationRefreshPreservesScopeAndIgnoresReplacementAuthority() async throws { + let service = SessionFixture(); let storage = LifecycleFixture() + let controller = SessionController(service: service, storage: storage) + await controller.restore() + let scope = try #require(controller.account?.scope) + await service.setVerified() + await controller.refreshUser() + #expect(controller.account?.scope == scope) + #expect(controller.account?.user.emailVerified == true) + #expect(await storage.activations.count == 1) + await service.signOut() + await controller.refreshUser() + #expect(controller.account?.scope == scope) + #expect(controller.phase == .signedIn) + } + + @Test func delayedProfileCannotRestoreSignedOutAccount() async { + let service = SessionFixture(); let storage = LifecycleFixture() + let controller = SessionController(service: service, storage: storage) + await controller.restore() + await service.configure(hold: true) + let refresh = Task { await controller.refreshUser() } + await service.awaitUserRequest() + await controller.signOut() + await service.releaseUser() + await refresh.value + #expect(controller.account == nil) + #expect(controller.phase == .signedOut) + } + + @Test func externalAuthenticationOwnsOneTicketAndResolvesCommittedAuthority() async throws { + let service = SessionFixture(signedIn: false); let storage = LifecycleFixture() + let controller = SessionController(service: service, storage: storage) + await controller.restore() + let ticket = try #require(controller.beginExternalAuthentication()) + #expect(controller.beginExternalAuthentication() == nil) + await service.signIn(email: "synthetic", password: "synthetic") + await controller.finishExternalAuthentication(ticket) + #expect(controller.phase == .signedIn) + await controller.cancelExternalAuthentication(ticket) + #expect(controller.phase == .signedIn) + } + + @Test func cancelledExternalAttemptReturnsToSignInAndStaleCompletionCannotPublish() async throws { + let service = SessionFixture(signedIn: false); let storage = LifecycleFixture() + let controller = SessionController(service: service, storage: storage) + await controller.restore() + var cancelled = 0 + controller.onCancelExternal = { cancelled += 1 } + let ticket = try #require(controller.beginExternalAuthentication()) + await controller.cancelExternalAuthentication(ticket) + #expect(controller.phase == .signedOut) + #expect(cancelled == 1) + await controller.signOut() + await controller.finishExternalAuthentication(ticket) + #expect(controller.account == nil) + #expect(await storage.activations.isEmpty) + } + + @Test func offlineRestoreDoesNotDeleteCredentialsOrDrafts() async { + let service = SessionFixture(); let storage = LifecycleFixture() + await service.configure(fail: true) + let controller = SessionController(service: service, storage: storage) + await controller.restore() + #expect(controller.phase == .unavailable) + #expect(controller.issue == .connection) + #expect(await service.identity() != nil) + #expect(await storage.clears == 0) + #expect(await storage.activations.isEmpty) + await service.configure() + await controller.restore() + #expect(controller.phase == .signedIn) + } + + @Test func samePrincipalReloginOwnsNewAppGeneration() async throws { + let service = SessionFixture(); let storage = LifecycleFixture() + let controller = SessionController(service: service, storage: storage) + await controller.restore() + let first = try #require(controller.account?.scope) + await controller.signOut() + #expect(controller.phase == .signedOut) + await controller.signIn(email: "fixture@example.test", password: "synthetic") + let second = try #require(controller.account?.scope) + #expect(first.principalID == second.principalID) + #expect(first.generation != second.generation) + #expect(await storage.retirements == [first]) + } + + @Test func delayedRestoreCannotPublishAfterSignOut() async { + let service = SessionFixture(); let storage = LifecycleFixture() + await service.configure(hold: true) + let controller = SessionController(service: service, storage: storage) + let restore = Task { await controller.restore() } + await service.awaitUserRequest() + await controller.signOut() + await service.releaseUser() + await restore.value + #expect(controller.phase == .signedOut) + #expect(controller.account == nil) + #expect(await storage.activations.isEmpty) + #expect(await service.identity() == nil) + } + + @Test func externalRetirementFailureStillRevokesCredentialsAndCleansDrafts() async throws { + let service = SessionFixture(); let storage = LifecycleFixture() + let controller = SessionController(service: service, storage: storage) + await controller.restore() + let scope = try #require(controller.account?.scope) + let failure = RetirementFailureControl() + controller.onRetire = { retired in + #expect(retired == scope) + if failure.shouldFail { throw ChatStorageError.cleanupRequired } + } + await controller.signOut() + #expect(controller.account == nil) + #expect(await service.identity() == nil) + #expect(await storage.retirements == [scope]) + #expect(controller.phase == .unavailable) + #expect(controller.issue == .cleanup) + await controller.signIn(email: "replacement@example.test", password: "synthetic") + #expect(await service.loginCount == 0) + failure.shouldFail = false + await controller.signOut() + #expect(controller.phase == .signedOut) + await controller.signIn(email: "replacement@example.test", password: "synthetic") + #expect(await service.loginCount == 1) + } + + @Test func cleanupFailureBlocksReplacementSignInUntilRetry() async { + let service = SessionFixture(); let storage = LifecycleFixture() + let controller = SessionController(service: service, storage: storage) + await controller.restore() + await storage.setFailure(true) + await controller.signOut() + #expect(controller.phase == .unavailable) + #expect(controller.issue == .cleanup) + #expect(controller.account == nil) + #expect(await service.identity() == nil) + await controller.signIn(email: "replacement@example.test", password: "synthetic") + #expect(await service.loginCount == 0) + await storage.setFailure(false) + await controller.signOut() + #expect(controller.phase == .signedOut) + await controller.signIn(email: "replacement@example.test", password: "synthetic") + #expect(await service.loginCount == 1) + #expect(controller.phase == .signedIn) + } +} diff --git a/apps/maple-ios/Tests/MapleAppCoreTests/Storage/ProtectedChatStoreTests.swift b/apps/maple-ios/Tests/MapleAppCoreTests/Storage/ProtectedChatStoreTests.swift new file mode 100644 index 000000000..306cc1bde --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppCoreTests/Storage/ProtectedChatStoreTests.swift @@ -0,0 +1,625 @@ +import Darwin +import Foundation +import Testing + +@testable import MapleAppCore + +@Suite("Protected draft storage") +struct ProtectedChatStoreTests { + private let account = AccountScope(apiOrigin: "http://127.0.0.1:1234", principalID: "fixture-A") + + private func directory() throws -> URL { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "maple-storage-test-" + UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false) + return root + } + + private func snapshot(key: String = ChatSnapshot.newChatKey, revision: UInt64 = 1, byte: UInt8 = 1) -> ChatSnapshot + { + ChatSnapshot( + storageKey: key, + draft: ChatDraft( + text: "local fixture", images: [ChatImageAttachment(data: Data([byte]), contentType: "image/png")]), + revision: revision) + } + + @Test func relaunchRestoresProtectedImagesWithoutInflatingMetadata() async throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let store = ProtectedChatStore(root: root) + try await store.activate(account) + var saved = snapshot() + saved.draft.images[0].data = Data(repeating: 7, count: 20 * 1_024 * 1_024) + try await store.save(saved, scope: account) + let names = try FileManager.default.contentsOfDirectory(atPath: root.path) + #expect(names.count == 2) + let manifest = root.appendingPathComponent(ProtectedChatFiles.accountName(account) + ".json") + #expect(try Data(contentsOf: manifest).count < 2_048) + for name in names { + let file = root.appendingPathComponent(name) + let attributes = try FileManager.default.attributesOfItem(atPath: file.path) + #expect((attributes[.posixPermissions] as? NSNumber)?.intValue == 0o600) + #expect(try file.resourceValues(forKeys: [.isExcludedFromBackupKey]).isExcludedFromBackup == true) + #if os(iOS) + #expect(attributes[.protectionKey] as? FileProtectionType == .complete) + #endif + } + #expect( + (try FileManager.default.attributesOfItem(atPath: root.path)[.posixPermissions] as? NSNumber)?.intValue + == 0o700) + let reopened = ProtectedChatStore(root: root) + try await reopened.activate( + AccountScope(apiOrigin: account.apiOrigin, principalID: account.principalID, generation: account.generation) + ) + #expect(try await reopened.load(chatID: saved.storageKey, scope: account) == saved) + } + + @Test func lockedFilesDeferChangesWithoutDeletingSavedDraft() async throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let store = ProtectedChatStore(root: root) + try await store.activate(account) + let saved = snapshot() + try await store.save(saved, scope: account) + await store.setProtectedDataAvailable(false) + var edited = saved + edited.draft.text = "waiting for unlock" + edited.revision += 1 + await #expect(throws: ChatStorageError.protectedDataUnavailable) { + try await store.save(edited, scope: account) + } + await #expect(throws: ChatStorageError.protectedDataUnavailable) { try await store.loadAll(scope: account) } + await store.setProtectedDataAvailable(true) + #expect(try await store.load(chatID: saved.storageKey, scope: account) == saved) + try await store.save(edited, scope: account) + #expect(try await store.load(chatID: saved.storageKey, scope: account) == edited) + } + + @Test func retiredGenerationCannotReactivateEvenForSamePrincipal() async throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let store = ProtectedChatStore(root: root) + try await store.activate(account) + let saved = snapshot() + try await store.save(saved, scope: account) + try await store.retire(account) + await #expect(throws: ChatStorageError.retiredOwner) { try await store.activate(account) } + await #expect(throws: ChatStorageError.retiredOwner) { try await store.save(saved, scope: account) } + let replacement = AccountScope(apiOrigin: account.apiOrigin, principalID: account.principalID) + try await store.activate(replacement) + #expect(try await store.loadAll(scope: replacement).isEmpty) + await #expect(throws: ChatStorageError.retiredOwner) { try await store.retire(account) } + } + + @Test func cleanupWhileLockedRevokesImmediatelyAndBlocksReplacement() async throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let store = ProtectedChatStore(root: root) + try await store.activate(account) + try await store.save(snapshot(), scope: account) + await store.setProtectedDataAvailable(false) + await #expect(throws: ChatStorageError.protectedDataUnavailable) { try await store.retire(account) } + await #expect(throws: ChatStorageError.retiredOwner) { try await store.loadAll(scope: account) } + let replacement = AccountScope(apiOrigin: account.apiOrigin, principalID: account.principalID) + await #expect(throws: ChatStorageError.protectedDataUnavailable) { try await store.activate(replacement) } + await store.setProtectedDataAvailable(true) + try await store.activate(replacement) + #expect(try await store.loadAll(scope: replacement).isEmpty) + #expect(try FileManager.default.contentsOfDirectory(atPath: root.path).isEmpty) + } + + @Test func accountAndOriginNamespacesDoNotShareAttachments() async throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let first = ProtectedChatStore(root: root) + try await first.activate(account) + let saved = snapshot() + try await first.save(saved, scope: account) + let other = AccountScope(apiOrigin: "http://127.0.0.1:5678", principalID: account.principalID) + let second = ProtectedChatStore(root: root) + try await second.activate(other) + #expect(try await second.loadAll(scope: other).isEmpty) + try await second.save(saved, scope: other) + #expect( + try FileManager.default.contentsOfDirectory(atPath: root.path).filter { $0.hasSuffix(".blob") }.count == 2) + try await first.retire(account) + let reopened = ProtectedChatStore(root: root) + try await reopened.activate(other) + #expect(try await reopened.load(chatID: saved.storageKey, scope: other) == saved) + } + + @Test func rekeyTombstonesOldOwnerAndRetryPreservesFreshNewChatAcrossRestart() async throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let store = ProtectedChatStore(root: root) + try await store.activate(account) + let original = snapshot() + try await store.save(original, scope: account) + var moved = original + moved.storageKey = "conversation-A" + moved.conversationID = "conversation-A" + moved.revision += 1 + try await store.rekey(from: original.storageKey, to: moved, scope: account) + var late = original + late.revision += 100 + await #expect(throws: ChatStorageError.staleRevision) { try await store.save(late, scope: account) } + let fresh = snapshot(byte: 2) + try await store.save(fresh, scope: account) + var edited = moved + edited.draft.text = "later conversation edit" + edited.revision += 1 + try await store.save(edited, scope: account) + try await store.rekey(from: original.storageKey, to: moved, scope: account) + #expect(try await store.load(chatID: fresh.storageKey, scope: account) == fresh) + #expect(try await store.load(chatID: moved.storageKey, scope: account) == edited) + let reopened = ProtectedChatStore(root: root) + try await reopened.activate(account) + await #expect(throws: ChatStorageError.staleRevision) { try await reopened.save(late, scope: account) } + try await reopened.rekey(from: original.storageKey, to: moved, scope: account) + #expect(try await reopened.loadAll(scope: account).count == 2) + } + + @Test func detachedPendingOwnerUsesOutgoingDraftAndCanBeDiscoveredAfterRestart() async throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let store = ProtectedChatStore(root: root) + try await store.activate(account) + let original = snapshot() + try await store.save(original, scope: account) + var pending = ChatSnapshot( + storageKey: "pending:" + UUID().uuidString, storageOwnerID: original.storageOwner, + pendingRun: ChatPendingRun( + id: UUID(), messageID: UUID().uuidString.lowercased(), outgoingDraft: original.draft, + options: ChatSendOptions()), + revision: original.revision + 1) + try await store.rekey(from: original.storageKey, to: pending, scope: account) + var oldComposer = original + oldComposer.revision = 99 + await #expect(throws: ChatStorageError.staleRevision) { try await store.save(oldComposer, scope: account) } + var blankComposer = pending + blankComposer.storageKey = original.storageKey + blankComposer.revision = 99 + await #expect(throws: ChatStorageError.staleRevision) { try await store.save(blankComposer, scope: account) } + let reopened = ProtectedChatStore(root: root) + try await reopened.activate(account) + #expect(try await reopened.loadAll(scope: account) == [pending]) + let pendingKey = pending.storageKey + pending.storageKey = "conversation-B" + pending.conversationID = pending.storageKey + pending.revision += 1 + try await reopened.rekey(from: pendingKey, to: pending, scope: account) + #expect(try await reopened.load(chatID: pendingKey, scope: account) == nil) + } + + @Test func collisionsAndStaleRevisionsLeaveBothSnapshotsIntact() async throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let store = ProtectedChatStore(root: root) + try await store.activate(account) + let source = snapshot() + let destination = snapshot(key: "occupied", byte: 2) + try await store.save(source, scope: account) + try await store.save(destination, scope: account) + var moved = source + moved.storageKey = destination.storageKey + moved.revision += 1 + await #expect(throws: ChatStorageError.destinationOccupied) { + try await store.rekey(from: source.storageKey, to: moved, scope: account) + } + var stale = source + stale.draft.text = "conflicting same revision" + await #expect(throws: ChatStorageError.staleRevision) { try await store.save(stale, scope: account) } + try await store.save(source, scope: account) + #expect(try await store.load(chatID: source.storageKey, scope: account) == source) + #expect(try await store.load(chatID: destination.storageKey, scope: account) == destination) + } + + @Test(arguments: [ProtectedChatStore.Publication.image, .manifest]) + func interruptedPublicationPreservesLastCommittedSnapshot(point: ProtectedChatStore.Publication) async throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let failure = PublicationFailure() + let store = ProtectedChatStore(root: root, limits: .init(), beforePublication: { try failure.check($0) }) + try await store.activate(account) + let saved = snapshot() + try await store.save(saved, scope: account) + var edited = saved + edited.draft.images[0].data = Data([9, 8, 7]) + edited.revision += 1 + failure.arm(point) + await #expect(throws: ChatStorageError.writeFailed) { try await store.save(edited, scope: account) } + #expect(try await store.load(chatID: saved.storageKey, scope: account) == saved) + let reopened = ProtectedChatStore(root: root) + try await reopened.activate(account) + #expect(try await reopened.load(chatID: saved.storageKey, scope: account) == saved) + #expect(try FileManager.default.contentsOfDirectory(atPath: root.path).count == 2) + } + + @Test func failedRekeyDoesNotPublishTombstoneOrLoseSource() async throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let failure = PublicationFailure() + let store = ProtectedChatStore(root: root, limits: .init(), beforePublication: { try failure.check($0) }) + try await store.activate(account) + let original = snapshot() + try await store.save(original, scope: account) + var moved = original + moved.storageKey = "conversation-pending" + moved.revision += 1 + failure.arm(.manifest) + await #expect(throws: ChatStorageError.writeFailed) { + try await store.rekey(from: original.storageKey, to: moved, scope: account) + } + #expect(try await store.load(chatID: original.storageKey, scope: account) == original) + #expect(try await store.load(chatID: moved.storageKey, scope: account) == nil) + let reopened = ProtectedChatStore(root: root) + try await reopened.activate(account) + var edited = original + edited.revision += 1 + edited.draft.text = "preserved source remains editable" + try await reopened.save(edited, scope: account) + moved.revision = edited.revision + 1 + try await reopened.rekey(from: edited.storageKey, to: moved, scope: account) + #expect(try await reopened.load(chatID: original.storageKey, scope: account) == nil) + } + + @Test(arguments: [false, true]) + func linkedAttachmentCannotBeReadThroughManifest(hardlink: Bool) async throws { + let root = try directory() + let outside = try directory() + defer { try? FileManager.default.removeItem(at: root); try? FileManager.default.removeItem(at: outside) } + let store = ProtectedChatStore(root: root) + try await store.activate(account) + try await store.save(snapshot(), scope: account) + let blobName = try #require( + FileManager.default.contentsOfDirectory(atPath: root.path).first { $0.hasSuffix(".blob") }) + let blob = root.appendingPathComponent(blobName) + let target = outside.appendingPathComponent("sentinel") + try FileManager.default.moveItem(at: blob, to: target) + if hardlink { + try FileManager.default.linkItem(at: target, to: blob) + } else { + try FileManager.default.createSymbolicLink(at: blob, withDestinationURL: target) + } + let reopened = ProtectedChatStore(root: root) + await #expect(throws: ChatStorageError.unsafePath) { try await reopened.activate(account) } + #expect(try Data(contentsOf: target) == Data([1])) + } + + @Test func changedAttachmentBytesDoNotMutatePreviousCommittedBlob() async throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let store = ProtectedChatStore(root: root) + try await store.activate(account) + let saved = snapshot() + try await store.save(saved, scope: account) + var second = saved + second.storageKey = "second-chat" + second.storageOwnerID = UUID() + second.draft.images[0].data = Data([2]) + try await store.save(second, scope: account) + let reopened = ProtectedChatStore(root: root) + try await reopened.activate(account) + #expect(try await reopened.load(chatID: saved.storageKey, scope: account) == saved) + #expect(try await reopened.load(chatID: second.storageKey, scope: account) == second) + try await store.remove(chatID: saved.storageKey, scope: account) + #expect( + try FileManager.default.contentsOfDirectory(atPath: root.path).filter { $0.hasSuffix(".blob") }.count == 1) + } + + @Test func limitsRejectWholeSaveWithoutPublishingPartialAttachments() async throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let limits = ProtectedChatStore.Limits( + metadataBytes: 2_048, imageBytes: 4, accountImageBytes: 5, retainedImages: 2, imagesPerDraft: 2) + let store = ProtectedChatStore(root: root, limits: limits, beforePublication: { _ in }) + try await store.activate(account) + let saved = snapshot() + try await store.save(saved, scope: account) + var edited = saved + edited.revision += 1 + edited.draft.images[0].data = Data(count: 5) + await #expect(throws: ChatStorageError.capacityExceeded) { try await store.save(edited, scope: account) } + edited.draft.images = [ + ChatImageAttachment(data: Data(count: 3), contentType: "image/png"), + ChatImageAttachment(data: Data(count: 3), contentType: "image/png"), + ] + await #expect(throws: ChatStorageError.capacityExceeded) { try await store.save(edited, scope: account) } + edited.draft.images = Array(repeating: saved.draft.images[0], count: 3) + await #expect(throws: ChatStorageError.capacityExceeded) { try await store.save(edited, scope: account) } + edited.draft.images = [] + edited.draft.text = String(repeating: "x", count: 3_000) + await #expect(throws: ChatStorageError.capacityExceeded) { try await store.save(edited, scope: account) } + #expect(try await store.load(chatID: saved.storageKey, scope: account) == saved) + #expect(try FileManager.default.contentsOfDirectory(atPath: root.path).count == 2) + let production = ProtectedChatStore.Limits() + #expect(production.imageBytes == 20 * 1_024 * 1_024) + #expect(production.accountImageBytes == 256 * 1_024 * 1_024) + #expect(production.retainedImages == 640) + #expect(production.imagesPerDraft == 10) + } + + @Test func sharedImageIdentityAcrossDraftsCountsOnceButChangedBytesCountSeparately() async throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let limits = ProtectedChatStore.Limits( + metadataBytes: 8_192, imageBytes: 2, accountImageBytes: 2, retainedImages: 1, imagesPerDraft: 10) + let store = ProtectedChatStore(root: root, limits: limits, beforePublication: { _ in }) + try await store.activate(account) + let image = ChatImageAttachment(data: Data([1, 2]), contentType: "image/png") + var saved = snapshot() + saved.draft.images = [image] + saved.pendingRun = ChatPendingRun( + id: UUID(), messageID: UUID().uuidString.lowercased(), outgoingDraft: ChatDraft(images: [image]), + options: ChatSendOptions()) + saved.unsentTurn = ChatDraft(images: [image]) + try await store.save(saved, scope: account) + let reopened = ProtectedChatStore(root: root, limits: limits, beforePublication: { _ in }) + try await reopened.activate(account) + #expect(try await reopened.load(chatID: saved.storageKey, scope: account) == saved) + var edited = saved + edited.revision += 1 + edited.unsentTurn?.images[0].data = Data([3, 4]) + await #expect(throws: ChatStorageError.capacityExceeded) { try await reopened.save(edited, scope: account) } + #expect(try await reopened.load(chatID: saved.storageKey, scope: account) == saved) + } + + @Test func signedOutCleanupFailureAlsoBlocksReplacementActivation() async throws { + let root = try directory() + let outside = try directory() + defer { try? FileManager.default.removeItem(at: root); try? FileManager.default.removeItem(at: outside) } + let target = outside.appendingPathComponent("sentinel") + try Data([7]).write(to: target) + let manifest = root.appendingPathComponent(ProtectedChatFiles.accountName(account) + ".json") + try FileManager.default.createSymbolicLink(at: manifest, withDestinationURL: target) + let store = ProtectedChatStore(root: root) + await #expect(throws: ChatStorageError.cleanupRequired) { try await store.clearSignedOutData() } + let other = AccountScope(apiOrigin: account.apiOrigin, principalID: "fixture-B") + await #expect(throws: ChatStorageError.cleanupRequired) { try await store.activate(other) } + #expect(try Data(contentsOf: target) == Data([7])) + try FileManager.default.removeItem(at: manifest) + try await store.activate(other) + #expect(try await store.loadAll(scope: other).isEmpty) + } + + @Test func queueEditOriginalWorkingAndHeldImagesSurviveAsOneAtomicSnapshot() async throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let limits = ProtectedChatStore.Limits( + metadataBytes: 64 * 1_024, imageBytes: 1, accountImageBytes: 3, retainedImages: 3) + let store = ProtectedChatStore(root: root, limits: limits, beforePublication: { _ in }) + try await store.activate(account) + let original = ChatQueuedTurn(draft: snapshot(byte: 1).draft) + let held = snapshot(byte: 2).draft + var working = snapshot(byte: 3).draft + working.text = String(repeating: "a", count: 33 * 1_024) + let saved = ChatSnapshot( + storageKey: "queue-edit-chat", draft: working, + queue: ChatQueueState( + items: [original], edit: ChatQueueEdit(original: original, originalIndex: 0, heldDraft: held), + paused: true), revision: 1) + try await store.save(saved, scope: account) + let reopened = ProtectedChatStore(root: root, limits: limits, beforePublication: { _ in }) + try await reopened.activate(account) + #expect(try await reopened.load(chatID: saved.storageKey, scope: account) == saved) + #expect(try FileManager.default.contentsOfDirectory(atPath: root.path).count == 4) + var attemptedSave = saved + attemptedSave.revision += 1 + attemptedSave.queue?.items[0].draft = working + attemptedSave.queue?.edit = nil + await #expect(throws: ChatStorageError.capacityExceeded) { + try await reopened.save(attemptedSave, scope: account) + } + #expect(try await reopened.load(chatID: saved.storageKey, scope: account) == saved) + } + + @Test func accountQueueCapacityIncludesPoppedActiveItem() async throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let store = ProtectedChatStore(root: root) + try await store.activate(account) + for index in 0..<4 { + let items = (0..<16).map { ChatQueuedTurn(draft: ChatDraft(text: "queued \($0)")) } + try await store.save( + ChatSnapshot(storageKey: "chat-\(index)", queue: ChatQueueState(items: items), revision: 1), + scope: account) + } + let active = ChatQueuedTurn(draft: ChatDraft(text: "popped but reserved")) + var run = ChatPendingRun( + id: UUID(), messageID: active.messageID, outgoingDraft: active.draft, options: active.options) + run.queuedTurn = active + let overflow = ChatSnapshot(storageKey: "active-queue-chat", pendingRun: run, revision: 1) + await #expect(throws: ChatStorageError.capacityExceeded) { try await store.save(overflow, scope: account) } + #expect(try await store.loadAll(scope: account).count == 4) + try await store.remove(chatID: "chat-0", scope: account) + try await store.save(overflow, scope: account) + #expect(try await store.load(chatID: overflow.storageKey, scope: account) == overflow) + } + + @Test func perChatWaitingLimitDoesNotChargePoppedActiveItemTwice() async throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let store = ProtectedChatStore(root: root) + try await store.activate(account) + let items = (0..<16).map { ChatQueuedTurn(draft: ChatDraft(text: "queued \($0)")) } + let active = ChatQueuedTurn(draft: ChatDraft(text: "active queue item")) + var run = ChatPendingRun( + id: UUID(), messageID: active.messageID, outgoingDraft: active.draft, options: active.options) + run.queuedTurn = active + let saved = ChatSnapshot(pendingRun: run, queue: ChatQueueState(items: items), revision: 1) + try await store.save(saved, scope: account) + var overflow = saved + overflow.revision += 1 + overflow.queue?.items.append(ChatQueuedTurn(draft: ChatDraft(text: "seventeenth waiting item"))) + await #expect(throws: ChatStorageError.capacityExceeded) { try await store.save(overflow, scope: account) } + #expect(try await store.load(chatID: saved.storageKey, scope: account) == saved) + } + + @Test func inconsistentQueueEditCannotReplaceCommittedTransaction() async throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let store = ProtectedChatStore(root: root) + try await store.activate(account) + let original = ChatQueuedTurn(draft: ChatDraft(text: "original")) + let saved = ChatSnapshot(queue: ChatQueueState(items: [original]), revision: 1) + try await store.save(saved, scope: account) + var invalid = saved + invalid.revision += 1 + invalid.queue?.edit = ChatQueueEdit(original: original, originalIndex: 1, heldDraft: ChatDraft(text: "held")) + await #expect(throws: ChatStorageError.corruptData) { try await store.save(invalid, scope: account) } + #expect(try await store.load(chatID: saved.storageKey, scope: account) == saved) + } + + @Test func malformedClientMessageIdentityCannotReplaceSavedWork() async throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let store = ProtectedChatStore(root: root) + try await store.activate(account) + let saved = snapshot() + try await store.save(saved, scope: account) + var invalid = saved + invalid.revision += 1 + invalid.pendingRun = ChatPendingRun( + id: UUID(), messageID: "malformed-message-id", outgoingDraft: ChatDraft(text: "outgoing"), + options: ChatSendOptions()) + await #expect(throws: ChatStorageError.corruptData) { try await store.save(invalid, scope: account) } + let turn = ChatQueuedTurn(draft: ChatDraft(text: "queued")) + invalid.pendingRun?.messageID = UUID().uuidString.lowercased() + invalid.pendingRun?.queuedTurn = turn + await #expect(throws: ChatStorageError.corruptData) { try await store.save(invalid, scope: account) } + #expect(try await store.load(chatID: saved.storageKey, scope: account) == saved) + } + + @Test func legacyStorageOwnerCanBeFrozenBeforeComposerChanges() async throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let store = ProtectedChatStore(root: root) + try await store.activate(account) + var legacy = snapshot() + legacy.storageOwnerID = nil + try await store.save(legacy, scope: account) + var edited = legacy + edited.storageOwnerID = legacy.storageOwner + edited.draft = ChatDraft(text: "new visible owner") + edited.revision += 1 + try await store.save(edited, scope: account) + var moved = edited + moved.storageKey = "conversation-with-new-composer" + moved.revision += 1 + try await store.rekey(from: edited.storageKey, to: moved, scope: account) + #expect(try await store.load(chatID: moved.storageKey, scope: account) == moved) + var late = legacy + late.revision = 100 + await #expect(throws: ChatStorageError.staleRevision) { try await store.save(late, scope: account) } + } + + @Test(arguments: [false, true]) + func linkedManifestsFailClosedIncludingDanglingSymlinks(hardlink: Bool) async throws { + let root = try directory() + let outside = try directory() + defer { try? FileManager.default.removeItem(at: root); try? FileManager.default.removeItem(at: outside) } + let target = outside.appendingPathComponent("sentinel") + let manifest = root.appendingPathComponent(ProtectedChatFiles.accountName(account) + ".json") + if hardlink { + try Data("unrelated".utf8).write(to: target) + try FileManager.default.linkItem(at: target, to: manifest) + } else { + try FileManager.default.createSymbolicLink(at: manifest, withDestinationURL: target) + } + let store = ProtectedChatStore(root: root) + await #expect(throws: ChatStorageError.unsafePath) { try await store.activate(account) } + if hardlink { + #expect(try String(contentsOf: target, encoding: .utf8) == "unrelated") + } else { + #expect(!FileManager.default.fileExists(atPath: target.path)) + } + } + + @Test func symlinkedRootIsNotUsed() async throws { + let parent = try directory() + defer { try? FileManager.default.removeItem(at: parent) } + let target = parent.appendingPathComponent("target") + try FileManager.default.createDirectory(at: target, withIntermediateDirectories: false) + let link = parent.appendingPathComponent("link") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: target) + let store = ProtectedChatStore(root: link) + await #expect(throws: ChatStorageError.unsafePath) { try await store.activate(account) } + #expect(try FileManager.default.contentsOfDirectory(atPath: target.path).isEmpty) + } + + @Test func blobCorruptionIsDetectedAndCleanupFailureBlocksSameAccountSignIn() async throws { + let root = try directory() + let outside = try directory() + defer { try? FileManager.default.removeItem(at: root); try? FileManager.default.removeItem(at: outside) } + let store = ProtectedChatStore(root: root) + try await store.activate(account) + try await store.save(snapshot(), scope: account) + let blobName = try #require( + FileManager.default.contentsOfDirectory(atPath: root.path).first { $0.hasSuffix(".blob") }) + let blob = root.appendingPathComponent(blobName) + try Data([2]).write(to: blob) + let reopened = ProtectedChatStore(root: root) + await #expect(throws: ChatStorageError.corruptData) { try await reopened.activate(account) } + try FileManager.default.removeItem(at: blob) + let target = outside.appendingPathComponent("sentinel") + try Data([42]).write(to: target) + try FileManager.default.createSymbolicLink(at: blob, withDestinationURL: target) + await #expect(throws: ChatStorageError.cleanupRequired) { try await store.retire(account) } + let replacement = AccountScope(apiOrigin: account.apiOrigin, principalID: account.principalID) + await #expect(throws: ChatStorageError.cleanupRequired) { try await store.activate(replacement) } + #expect(try Data(contentsOf: target) == Data([42])) + try FileManager.default.removeItem(at: blob) + try await store.activate(replacement) + #expect(try await store.loadAll(scope: replacement).isEmpty) + } + + @Test func signedOutCleanupOnlyRemovesRecognizedOwnedNamesAndCrashTemporaries() async throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let store = ProtectedChatStore(root: root) + try await store.activate(account) + try await store.save(snapshot(), scope: account) + let temporary = ".pending-" + ProtectedChatFiles.accountName(account) + "-" + UUID().uuidString + try Data([3]).write(to: root.appendingPathComponent(temporary)) + try Data([4]).write(to: root.appendingPathComponent("unrelated.json")) + let signedOut = ProtectedChatStore(root: root) + try await signedOut.clearSignedOutData() + #expect(try FileManager.default.contentsOfDirectory(atPath: root.path) == ["unrelated.json"]) + } + + @Test func inlineVersionOneMigratesAndUnknownVersionDoesNotOverwriteData() async throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + struct Legacy: Codable { + let version: Int; let origin: String; let principal: String; let chats: [String: ChatSnapshot] + } + let saved = snapshot() + let manifest = root.appendingPathComponent(ProtectedChatFiles.accountName(account) + ".json") + let legacy = Legacy( + version: 1, origin: account.apiOrigin, principal: account.principalID, chats: [saved.storageKey: saved]) + try JSONEncoder().encode(legacy).write(to: manifest) + let store = ProtectedChatStore(root: root) + try await store.activate(account) + #expect(try await store.load(chatID: saved.storageKey, scope: account) == saved) + try await store.save(saved, scope: account) + let migrated = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: manifest)) as? [String: Any]) + #expect(migrated["version"] as? Int == 2) + #expect(try FileManager.default.contentsOfDirectory(atPath: root.path).count == 2) + let future = Legacy(version: 99, origin: account.apiOrigin, principal: account.principalID, chats: [:]) + let futureData = try JSONEncoder().encode(future) + try futureData.write(to: manifest) + let unsupported = ProtectedChatStore(root: root) + await #expect(throws: ChatStorageError.unsupportedVersion) { try await unsupported.activate(account) } + #expect(try Data(contentsOf: manifest) == futureData) + } +} + +private final class PublicationFailure: @unchecked Sendable { + private let lock = NSLock() + private var point: ProtectedChatStore.Publication? + func arm(_ point: ProtectedChatStore.Publication) { lock.withLock { self.point = point } } + func check(_ point: ProtectedChatStore.Publication) throws { + try lock.withLock { if self.point == point { throw ChatStorageError.writeFailed } } + } +} diff --git a/apps/maple-ios/Tests/MapleAppUITests/Audio/NativeSpeechPitchTests.swift b/apps/maple-ios/Tests/MapleAppUITests/Audio/NativeSpeechPitchTests.swift new file mode 100644 index 000000000..7dbc6f613 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppUITests/Audio/NativeSpeechPitchTests.swift @@ -0,0 +1,63 @@ +import AVFoundation +import Foundation +import Testing + +/// Objective software-rendering proof for the selected time-pitch mechanism. +/// This is not a claim about physical speakers, voice quality or locked playback. +@Suite(.serialized) struct NativeSpeechPitchTests { + @Test(arguments: [Float(1), 1.25, 1.5, 2]) + func savedRatesPreserveSyntheticPitch(rate: Float) throws { + let sampleRate = 48_000.0, frequency = 440.0, seconds = 3.0 + let format = try #require(AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 1)) + let input = try #require( + AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(seconds * sampleRate))) + input.frameLength = input.frameCapacity + let source = try #require(input.floatChannelData?[0]) + for frame in 0.. 0.02 { firstAudible = firstAudible ?? current; lastAudible = current } + let time = Double(current) / sampleRate + if time > 0.3 && time < seconds / Double(rate) - 0.3, lastSample <= 0, sample > 0 { + let fraction = Double(-lastSample / (sample - lastSample)) + crossings.append(Double(current - 1) + fraction) + } + lastSample = sample + } + rendered += Int(output.frameLength) + } + #expect(rendered == framesToRender) + let first = try #require(crossings.first), last = try #require(crossings.last) + let measuredFrequency = Double(crossings.count - 1) * sampleRate / (last - first) + #expect(abs(measuredFrequency - frequency) < 4) + let audibleFirst = try #require(firstAudible), audibleLast = try #require(lastAudible) + let audibleDuration = Double(audibleLast - audibleFirst) / sampleRate + #expect(abs(audibleDuration - seconds / Double(rate)) < 0.25) + } +} diff --git a/apps/maple-ios/Tests/MapleAppUITests/ChatLegacyContentTests.swift b/apps/maple-ios/Tests/MapleAppUITests/ChatLegacyContentTests.swift new file mode 100644 index 000000000..bb9332a7a --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppUITests/ChatLegacyContentTests.swift @@ -0,0 +1,169 @@ +import Foundation +import Testing +@testable import MapleAppUI + +@Suite struct ChatLegacyContentTests { + @Test func pendingDocumentNeverFallsThroughToRawJSONAndInvalidContentRecovers() throws { + let generation = UUID() + let source = ChatLegacyContent.Source( + text: #"{"document":{"filename":"notes.txt","text_content":"Private document"}} Prompt"#, + user: true, assistant: false, streaming: false, generation: generation) + #expect(ChatLegacyContent.presentation(source: source, prepared: nil) == .pendingDocument) + let prepared = ChatLegacyContent.prepare(source) + let document = try #require(prepared.document) + #expect(ChatLegacyContent.presentation(source: source, prepared: prepared) == .document(document)) + + let incomplete = ChatLegacyContent.Source( + text: #"{"document":{"filename":"notes.txt","text_content":"unfinished"}"#, + user: true, assistant: false, streaming: false, generation: generation) + #expect(ChatLegacyContent.presentation(source: incomplete, prepared: prepared) == .pendingDocument) + #expect( + ChatLegacyContent.presentation(source: incomplete, prepared: ChatLegacyContent.prepare(incomplete)) + == .ordinary) + } + + @Test func oldAccountDocumentPreparationCannotRenderForTheNewGeneration() { + let text = #"{"document":{"filename":"notes.txt","text_content":"Private document"}}"# + let old = ChatLegacyContent.Source( + text: text, user: true, assistant: false, streaming: false, generation: UUID()) + let current = ChatLegacyContent.Source( + text: text, user: true, assistant: false, streaming: false, generation: UUID()) + #expect( + ChatLegacyContent.presentation(source: current, prepared: ChatLegacyContent.prepare(old)) + == .pendingDocument) + } + + @Test func firstThinkingTagWaitsWithAVisibleStateInsteadOfEmptyOrdinaryParts() { + let generation = UUID() + let ordinary = ChatLegacyContent.Source( + text: "", user: false, assistant: false, streaming: true, generation: generation) + let thinking = ChatLegacyContent.Source( + text: "", user: false, assistant: true, streaming: true, generation: generation) + #expect( + ChatLegacyContent.presentation(source: thinking, prepared: ChatLegacyContent.prepare(ordinary)) + == .pendingThinking) + let prepared = ChatLegacyContent.prepare(thinking) + #expect(prepared.parts.count == 1 && prepared.parts[0].kind == .thinking) + #expect(ChatLegacyContent.presentation(source: thinking, prepared: prepared) == .assistant(prepared.parts)) + } + + @Test func streamingAppendKeepsRichPartsUntilTheExactNewSourcePublishes() { + let generation = UUID() + let previous = ChatLegacyContent.Source( + text: "Reasoning**Answer**", user: false, assistant: true, streaming: true, + generation: generation) + let appended = ChatLegacyContent.Source( + text: previous.text + " with [source](https://example.com)", user: false, assistant: true, + streaming: true, generation: generation) + let retained = ChatLegacyContent.prepare(previous) + #expect(ChatLegacyContent.presentation(source: appended, prepared: retained) == .assistant(retained.parts)) + let current = ChatLegacyContent.prepare(appended) + #expect(ChatLegacyContent.presentation(source: appended, prepared: current) == .assistant(current.parts)) + #expect(current.parts.last?.text == "**Answer** with [source](https://example.com)") + #expect(current.parts.first?.kind == .thinking && current.parts.first?.text == "Reasoning") + } + + @Test func rewrittenSettledAndNewAccountThinkingCannotReuseStreamingParts() { + let generation = UUID() + let previous = ChatLegacyContent.Source( + text: "Old reasoning", user: false, assistant: true, streaming: true, generation: generation) + let retained = ChatLegacyContent.prepare(previous) + let rewritten = ChatLegacyContent.Source( + text: "Different reasoning", user: false, assistant: true, streaming: true, generation: generation) + let settled = ChatLegacyContent.Source( + text: previous.text, user: false, assistant: true, streaming: false, generation: generation) + let nextAccount = ChatLegacyContent.Source( + text: previous.text + " continues", user: false, assistant: true, streaming: true, generation: UUID()) + for source in [rewritten, settled, nextAccount] { + #expect(ChatLegacyContent.presentation(source: source, prepared: retained) == .pendingThinking) + } + let current = ChatLegacyContent.prepare(settled) + #expect(ChatLegacyContent.presentation(source: settled, prepared: current) == .assistant(current.parts)) + #expect(current.parts.first?.isThinking == false) + } + + @Test func ordinaryTextBypassesLegacyPreparationAndDoesNotReuseOldContent() { + let generation = UUID() + let old = ChatLegacyContent.Source( + text: "ReasoningAnswer", user: false, assistant: true, streaming: false, + generation: generation) + let source = ChatLegacyContent.Source( + text: "**Plain answer**", user: false, assistant: false, streaming: false, generation: generation) + #expect(!source.requiresPreparation) + #expect(ChatLegacyContent.presentation(source: source, prepared: ChatLegacyContent.prepare(old)) == .ordinary) + } + + @Test func documentSplitsEscapedPrefixFromOrdinaryUserText() throws { + let source = + #"Before {"document":{"filename":"notes}.txt","text_content":"A \"quoted\" {line}\nSecond line"}} After **prompt**"# + let document = try #require(ChatLegacyContent.document(in: source)) + #expect(document.filename == "notes}.txt") + #expect(document.text == "A \"quoted\" {line}\nSecond line") + #expect(document.before == "Before") + #expect(document.after == "After **prompt**") + } + + @Test(arguments: [ + #"{"document":{"filename":"notes.txt","text_content":null}}"#, + #"{"document":{"filename":7,"text_content":"notes"}}"#, + #"{"document":{"filename":"notes.txt"}}"#, + #"{"document":{"filename":"notes.txt","text_content":"unfinished"}"#, + #"{"document" :{"filename":"notes.txt","text_content":"not the legacy prefix"}}"#, + ]) + func invalidOrIncompleteDocumentRemainsOrdinaryText(_ source: String) { + #expect(ChatLegacyContent.document(in: source) == nil) + } + + @Test func emptyDocumentContentIsStillAValidPreview() throws { + let document = try #require( + ChatLegacyContent.document(in: #"{"document":{"filename":"empty.txt","text_content":""}}"#)) + #expect(document.text.isEmpty && document.before.isEmpty && document.after.isEmpty) + } + + @Test func documentScannerReadsJSONQuotesBeforeACombiningFilenameScalar() throws { + let document = try #require( + ChatLegacyContent.document( + in: + "{\"document\":{\"filename\":\"\u{0301}notes.txt\",\"text_content\":\"Text\"}} Prompt")) + #expect(document.filename == "\u{0301}notes.txt") + #expect(document.text == "Text" && document.after == "Prompt") + } + + @Test func storedThinkingTagsRetainTextOrderAndStableOffsets() { + let parts = ChatLegacyContent.assistantParts( + "BeforeReasoningAfterMoreEnd", streaming: false) + #expect(parts.map(\.kind) == [.text, .thinking, .text, .thinking, .text]) + #expect(parts.map(\.text) == ["Before", "Reasoning", "After", "More", "End"]) + #expect(parts.map(\.id) == [0, 6, 30, 35, 54]) + #expect(parts.allSatisfy { !$0.isThinking }) + } + + @Test func streamingEmptyAndUnclosedThinkingRemainVisibleUntilSettled() { + let empty = ChatLegacyContent.assistantParts("", streaming: true) + #expect(empty.count == 1 && empty[0].kind == .thinking && empty[0].isThinking) + #expect(ChatLegacyContent.assistantParts(" \n", streaming: false).isEmpty) + let unfinished = ChatLegacyContent.assistantParts("Still thinking", streaming: true) + #expect(unfinished.last?.isThinking == true) + let recovered = ChatLegacyContent.assistantParts("Still thinking", streaming: false) + #expect(recovered.last?.text == "Still thinking" && recovered.last?.isThinking == false) + let closed = ChatLegacyContent.assistantParts("DoneAnswer", streaming: true) + #expect(closed.map(\.kind) == [.thinking, .text]) + #expect(closed.allSatisfy { !$0.isThinking }) + } + + @Test func ordinaryMarkdownAndPartialOpeningTagsStayUnchanged() { + for text in ["**Answer**\n\n[Link](https://example.com)", ""] { + let parts = ChatLegacyContent.assistantParts(text, streaming: true) + #expect(parts.count == 1 && parts[0].kind == .text && parts[0].text == text) + } + } + + @Test func completedStandaloneUnclosedThinkingTagRecoversTheAnswerAsText() { + let completed = ChatLegacyContent.assistantParts("\nAnswer", streaming: false) + #expect(completed.count == 1 && completed[0].kind == .text && completed[0].text == "Answer") + let streaming = ChatLegacyContent.assistantParts("\nAnswer", streaming: true) + #expect(streaming.count == 1 && streaming[0].kind == .thinking && streaming[0].isThinking) + let nonemptyOpening = ChatLegacyContent.assistantParts("Reasoning\nAnswer", streaming: false) + #expect(nonemptyOpening.count == 1 && nonemptyOpening[0].kind == .thinking) + } +} diff --git a/apps/maple-ios/Tests/MapleAppUITests/ChatToolPresentationTests.swift b/apps/maple-ios/Tests/MapleAppUITests/ChatToolPresentationTests.swift new file mode 100644 index 000000000..0d5b9d6ec --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppUITests/ChatToolPresentationTests.swift @@ -0,0 +1,104 @@ +import Foundation +import MapleSDK +import Testing +@testable import MapleAppUI + +@Suite struct ChatToolPresentationTests { + private let english = Locale(identifier: "en") + + @Test func storedToolNamesUseResearchKindsWithoutEnablingTools() { + let cases: [(String?, ChatToolPresentation.Kind)] = [ + ("shell", .shell), ("developer__shell", .shell), ("read_image", .fileRead), + ("read_file", .fileRead), ("glob", .fileRead), ("grep", .fileRead), + ("apply_patch", .fileWrite), ("developer__str_replace_editor", .fileWrite), + ("web_search", .web), ("open_urls", .web), ("open_url", .web), + ("calendar__read_file", .mcp), ("unknown", .generic), (nil, .generic), + ] + for (name, expected) in cases { #expect(ChatToolPresentation.kind(name: name) == expected) } + #expect(ChatToolPresentation.kind(name: nil, webSearch: true) == .web) + #expect(ChatToolPresentation.kind(name: "unknown").systemImage == "wrench") + #expect(ChatToolPresentation.kind(name: "open_urls").systemImage == "globe") + } + + @Test func titlesDecodeStringArgumentsAndKeepUserValuesVerbatim() { + #expect( + ChatToolPresentation.title( + name: "web_search", arguments: .string(#"{"query":" garden "}"#), locale: english) + == "Web Search: \" garden \"") + #expect( + ChatToolPresentation.title( + name: "open_url", arguments: .object(["url": .string("https://example.com")]), locale: english) + == "Open URL: https://example.com") + #expect( + ChatToolPresentation.title( + name: "open_urls", arguments: .object(["urls": .array([.string(" "), .integer(3), .string("one")])]), + locale: english) == "Open URL: one") + #expect( + ChatToolPresentation.title( + name: "open_urls", arguments: .object(["urls": .array([.string("one"), .string("two")])]), + locale: english) == "Open URLs: 2 pages") + #expect( + ChatToolPresentation.title(name: "read_image", arguments: .string(#"{"image_number":2}"#), locale: english) + == "Read image: Image 2") + #expect( + ChatToolPresentation.title(name: "other", arguments: .object(["query": .string("term")]), locale: english) + == "other: \"term\"") + #expect(ChatToolPresentation.title(name: "function", arguments: nil, locale: english) == "Tool call") + #expect(ChatToolPresentation.title(name: nil, arguments: nil, webSearch: true, locale: english) == "Web Search") + } + + @Test func callStatusMatchesResearchPrecedenceAndOrphanOutputs() { + var call = ConversationItem(id: "call", type: "function_call", status: "completed") + var result = ConversationItem(id: "result", type: "function_call_output", status: "streaming") + #expect(ChatToolPresentation.status(item: call, result: nil) == .active) + #expect(ChatToolPresentation.status(item: call, result: result) == .active) + result.status = "completed" + #expect(ChatToolPresentation.status(item: call, result: result) == .completed) + call.status = "incomplete" + #expect(ChatToolPresentation.status(item: call, result: result) == .incomplete) + result.status = "failed" + #expect(ChatToolPresentation.status(item: call, result: result) == .failed) + result.status = nil + #expect(ChatToolPresentation.status(item: result, result: nil) == .completed) + result.status = "streaming" + #expect(ChatToolPresentation.status(item: result, result: nil) == .active) + call.type = "web_search_call"; call.status = "searching" + #expect(ChatToolPresentation.status(item: call, result: nil) == .active) + call.status = nil + #expect(ChatToolPresentation.status(item: call, result: nil) == .failed) + } + + @Test func inputIsPrettyJSONAndOutputIsMarkdownWithoutJSONQuoting() { + let call = ConversationItem(id: "call", type: "function_call", arguments: .string(#"{"query":"garden","n":2}"#)) + let result = ConversationItem( + id: "result", type: "function_call_output", output: .string("**Results**\n[Garden](https://example.com)")) + let details = ChatToolPresentation.details(item: call, result: result) + #expect(details.input.contains("\n") && details.input.contains("\"query\" : \"garden\"")) + #expect(details.output == "**Results**\n[Garden](https://example.com)") + #expect(ChatToolPresentation.details(item: result, result: nil).input.isEmpty) + let incomplete = ConversationItem(id: "incomplete", type: "tool_call", arguments: .string("{\"query\":")) + #expect(ChatToolPresentation.details(item: incomplete, result: nil).input == "{\"query\":") + } + + @Test func previewKeepsFullPlainURLDestinationsAndAvoidsBrokenMarkdownLinks() { + let url = "https://example.com/" + String(repeating: "long", count: 50) + let text = "See " + url + let preview = ChatToolPresentation.preview(text) + #expect(preview.hasPrefix("See [https://example.com/")) + #expect(preview.hasSuffix("](" + url + ")")) + let linked = String(repeating: "x", count: 130) + " [Garden](" + url + ")" + #expect(ChatToolPresentation.preview(linked) == String(repeating: "x", count: 130) + "...") + #expect( + ChatToolPresentation.preview("Small [link](https://example.com)") == "Small [link](https://example.com)") + let unicode = String(repeating: "a", count: 149) + "😀tail" + #expect(ChatToolPresentation.preview(unicode) == String(repeating: "a", count: 149) + "...") + } + + @Test func blankArgumentsDoNotProduceAnInputDisclosure() { + let blank = ConversationItem(id: "blank", type: "tool_call", arguments: .string(" \n\t ")) + let details = ChatToolPresentation.details(item: blank, result: nil) + #expect(details.input.isEmpty && !details.hasDetails) + let partial = ConversationItem(id: "partial", type: "tool_call", arguments: .string(" partial ")) + #expect(ChatToolPresentation.details(item: partial, result: nil).input == " partial ") + } +} diff --git a/apps/maple-ios/Tests/MapleAppUITests/ChatTranscriptHistoryAnchorTests.swift b/apps/maple-ios/Tests/MapleAppUITests/ChatTranscriptHistoryAnchorTests.swift new file mode 100644 index 000000000..51a3fa029 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppUITests/ChatTranscriptHistoryAnchorTests.swift @@ -0,0 +1,45 @@ +import MapleSDK +import Testing +@testable import MapleAppUI + +@Suite struct ChatTranscriptHistoryAnchorTests { + @Test(arguments: [ + (call: "function_call", output: "function_call_output"), + (call: "tool_call", output: "tool_output"), + ]) + func anOutputAnchorSurvivesTheEarlierPageRevealingItsCall(types: (call: String, output: String)) throws { + let call = ConversationItem(id: "call", type: types.call, status: "completed", callId: "search") + let output = ConversationItem( + id: "output", type: types.output, status: "completed", callId: "search", + output: .string("Synthetic search result")) + let reply = ConversationItem(id: "reply", type: "message", status: "completed", role: "assistant") + let before = try #require(ChatTranscriptRow.rows([output, reply]).first) + #expect(before.id == output.id && before.toolResult == nil) + #expect(before.historyAnchorIDs.contains(output.id)) + + let after = ChatTranscriptRow.rows([call, output, reply]) + let restored = try #require(after.first { $0.historyAnchorIDs.contains(output.id) }) + #expect(restored.id == call.id && restored.toolResult == output) + #expect(restored.historyAnchorIDs.contains(call.id)) + #expect(after.filter { $0.historyAnchorIDs.contains(output.id) }.count == 1) + #expect(after.map(\.id) == [call.id, reply.id]) + } + + @Test func existingReasoningAndAnswerAnchorsSurviveAPageStartingMidTurn() throws { + let user = ConversationItem(id: "user", type: "message", status: "completed", role: "user") + let reasoning = ConversationItem(id: "reasoning", type: "reasoning", status: "completed") + let answer = ConversationItem(id: "answer", type: "message", status: "completed", role: "assistant") + let before = ChatTranscriptRow.rows([reasoning, answer]) + let after = ChatTranscriptRow.rows([user, reasoning, answer]) + + // The older page supplies the user that owns these already visible + // items. Restoration must still resolve each persisted item directly. + for row in before { + let restored = try #require(after.first { $0.historyAnchorIDs.contains(row.item.id) }) + #expect(restored.id == row.id && restored.item == row.item) + #expect(restored.historyAnchorIDs == row.historyAnchorIDs) + } + #expect(!after[0].historyAnchorIDs.contains(reasoning.id)) + #expect(!after[0].historyAnchorIDs.contains(answer.id)) + } +} diff --git a/apps/maple-ios/Tests/MapleAppUITests/Composer/ComposerFlowTests.swift b/apps/maple-ios/Tests/MapleAppUITests/Composer/ComposerFlowTests.swift new file mode 100644 index 000000000..406e3d984 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppUITests/Composer/ComposerFlowTests.swift @@ -0,0 +1,282 @@ +import Foundation +import MapleAppCore +import MapleSDK +import Testing +@testable import MapleAppUI + +@Suite struct ComposerFlowTests { + @Test func nativeTextHeightIncludesPaddingAndPreservesAccessibilityLineHeight() { + #expect(ComposerEditorMetrics.preferredHeight(lineHeight: 22, contentHeight: 34, compact: false) == 44) + #expect(ComposerEditorMetrics.preferredHeight(lineHeight: 22, contentHeight: 1_000, compact: false) == 188) + #expect(ComposerEditorMetrics.preferredHeight(lineHeight: 52, contentHeight: 0, compact: true) == 64) + #expect(ComposerEditorMetrics.preferredHeight(lineHeight: 52, contentHeight: 0, compact: false) == 64) + #expect(ComposerEditorMetrics.topInset(height: 44, lineHeight: 22, compact: true) == 11) + #expect(ComposerEditorMetrics.topInset(height: 64, lineHeight: 52, compact: true) == 6) + #expect(ComposerEditorMetrics.topInset(height: 110, lineHeight: 22, compact: false) == 6) + } + + @Test func optionsAndMicrophoneKeepFullTargetsInCompactAndNarrowComposers() { + let sizes = [44, 180, 44, 44, 44].map { CGSize(width: CGFloat($0), height: 44) } + for width in [CGFloat(180), 200, 320, 350, 850] { + for expanded in [false, true] { + let plan = ComposerInputPlan( + sizes: sizes, editorIndex: 1, width: width, expanded: expanded, trailingIndices: [3, 4]) + let frames = plan.frames.compactMap { $0 } + #expect(frames.count == sizes.count) + #expect(plan.frames[2]?.width == 44 && plan.frames[3]?.width == 44) + #expect(plan.frames[3]?.minY == plan.frames[4]?.minY) + #expect(plan.frames[4]?.maxX == width) + for (index, frame) in frames.enumerated() { + #expect(frame.width >= 44 && frame.height >= 44) + #expect(frame.minX >= 0 && frame.maxX <= width) + for next in frames.dropFirst(index + 1) { #expect(!frame.intersects(next)) } + } + } + } + } + + @Test func compactActionsWrapWithoutOverlapOrDroppingIdentity() { + let widths: [CGFloat] = [44, 128, 44, 44, 44, 82, 44] + let sizes = widths.map { CGSize(width: $0, height: 44) } + let plan = ComposerFlowPlan(sizes: sizes, width: 350) + #expect(plan.frames.count == sizes.count) + #expect(plan.frames.compactMap { $0?.minY } == [0, 0, 0, 0, 0, 52, 52]) + #expect(plan.height == 96) + let frames = plan.frames.compactMap { $0 } + for (index, frame) in frames.enumerated() { + #expect(frame.minX >= 0 && frame.maxX <= 350) + for next in frames.dropFirst(index + 1) { #expect(!frame.intersects(next)) } + } + } + + @Test func accessibilityHeightAndEmptyConditionalDoNotCreatePhantomRows() { + let plan = ComposerFlowPlan( + sizes: [.init(width: 44, height: 44), .init(width: 300, height: 112), .zero, .init(width: 44, height: 44)], + width: 300) + #expect(plan.frames[0]?.origin == .zero) + #expect(plan.frames[1]?.minY == 52) + #expect(plan.frames[2] == nil) + #expect(plan.frames[3]?.minY == 172) + #expect(plan.height == 216) + #expect(ComposerFlowPlan(sizes: [.zero], width: 300).height == 0) + } + + @Test func exactFitAndWideTabletKeepOneRow() { + let sizes = [CGSize(width: 44, height: 44), CGSize(width: 44, height: 44)] + #expect(ComposerFlowPlan(sizes: sizes, width: 96).height == 44) + #expect(ComposerFlowPlan(sizes: sizes, width: 95).height == 96) + #expect(ComposerFlowPlan(sizes: Array(repeating: sizes[0], count: 7), width: 850).height == 44) + } + + @Test func sendUsesTrailingRowSpaceWithoutMovingOrOverlappingOtherControls() { + let sizes = [CGSize(width: 44, height: 44), CGSize(width: 128, height: 44), CGSize(width: 44, height: 44)] + let oneRow = ComposerFlowPlan(sizes: sizes, width: 350, trailingIndices: [2]) + #expect(oneRow.frames[0]?.minX == 0 && oneRow.frames[1]?.minX == 52) + #expect(oneRow.frames[2]?.maxX == 350) + let wrapped = ComposerFlowPlan(sizes: sizes, width: 200, trailingIndices: [2]) + #expect(wrapped.frames[2]?.minY == 52 && wrapped.frames[2]?.maxX == 200) + #expect(wrapped.height == 96) + } + + @Test func collapsedPillKeepsEditorBetweenAttachmentAndTrailingActions() { + let sizes = [44, 180, 80, 44, 44, 44].map { CGSize(width: CGFloat($0), height: 44) } + let plan = ComposerInputPlan( + sizes: sizes, editorIndex: 1, width: 350, expanded: false, + hiddenIndices: [2, 3], trailingIndices: [3, 4, 5]) + #expect(plan.height == 44) + #expect(plan.frames[2] == nil && plan.frames[3] == nil) + #expect(plan.frames[0]?.minX == 0) + #expect(plan.frames[1]?.minX == 48 && plan.frames[1]?.width == 206) + #expect(plan.frames[4]?.minX == 258 && plan.frames[5]?.maxX == 350) + let frames = plan.frames.compactMap { $0 } + for (index, frame) in frames.enumerated() { + #expect(frame.minX >= 0 && frame.maxX <= 350) + for next in frames.dropFirst(index + 1) { #expect(!frame.intersects(next)) } + } + } + + @Test func expandedEditorKeepsProjectMicrophoneAndActionTogetherAtTrailingEdge() { + var sizes = [44, 180, 80, 44, 44, 44].map { CGSize(width: CGFloat($0), height: 44) } + sizes[1].height = 110 + let plan = ComposerInputPlan( + sizes: sizes, editorIndex: 1, width: 350, expanded: true, trailingIndices: [3, 4, 5]) + #expect(plan.frames[1] == CGRect(x: 0, y: 0, width: 350, height: 110)) + #expect(plan.frames[3]?.minX == 210 && plan.frames[5]?.maxX == 350) + #expect(plan.frames[0]?.minY == 114 && plan.height == 158) + let narrow = ComposerInputPlan( + sizes: sizes, editorIndex: 1, width: 200, expanded: true, trailingIndices: [3, 4, 5]) + #expect(narrow.frames[3]?.minY == narrow.frames[4]?.minY) + #expect(narrow.frames[4]?.minY == narrow.frames[5]?.minY) + #expect(narrow.frames[5]?.maxX == 200) + #expect(narrow.height == 206) + } + + @Test func narrowPillUsesRowsInsteadOfSqueezingNativeEditorOrDroppingActions() { + let sizes = Array(repeating: CGSize(width: 44, height: 44), count: 4) + let plan = ComposerInputPlan( + sizes: sizes, editorIndex: 1, width: 180, expanded: false, trailingIndices: [2, 3]) + #expect(plan.frames[1] == CGRect(x: 0, y: 0, width: 180, height: 44)) + #expect(plan.height == 92) + #expect(plan.frames.compactMap { $0 }.count == 4) + for frame in plan.frames.compactMap({ $0 }) { + #expect(frame.width >= 44 && frame.height >= 44) + #expect(frame.minX >= 0 && frame.maxX <= 180) + } + } + + @Test func keyboardDismissalCollapsesDespiteRetainedHardwareFocus() { + #expect( + ChatComposerPresentation.expanded( + newConversation: false, editingQueue: false, focused: true, keyboardVisible: nil)) + #expect( + ChatComposerPresentation.expanded( + newConversation: false, editingQueue: false, focused: true, keyboardVisible: true)) + #expect( + !ChatComposerPresentation.expanded( + newConversation: false, editingQueue: false, focused: true, keyboardVisible: false)) + #expect( + !ChatComposerPresentation.expanded( + newConversation: false, editingQueue: false, focused: false, keyboardVisible: true)) + #expect( + ChatComposerPresentation.expanded( + newConversation: true, editingQueue: false, focused: false, keyboardVisible: false)) + #expect( + ChatComposerPresentation.expanded( + newConversation: false, editingQueue: true, focused: false, keyboardVisible: false)) + } + + @Test func toolCallAndExactResultKeepOneStableRowAndFailureDetails() { + let call = ConversationItem( + id: "call", type: "function_call", callId: "same", name: "web_search", arguments: .string("query")) + let result = ConversationItem( + id: "result", type: "function_call_output", status: "failed", callId: "same", + output: .string("failure details")) + let reply = ConversationItem(id: "reply", type: "message", role: "assistant") + let before = ChatTranscriptRow.rows([call, reply]) + let after = ChatTranscriptRow.rows([call, result, reply]) + #expect(before.map(\.id) == ["call", "reply"] && after.map(\.id) == before.map(\.id)) + #expect(after[0].item == call && after[0].toolResult == result) + #expect(after[1].toolResult == nil) + } + + @Test func ambiguousUnmatchedAndOutOfOrderToolResultsAreNeverHidden() { + let call = ConversationItem(id: "call", type: "function_call", callId: "same") + let first = ConversationItem( + id: "first", type: "function_call_output", callId: "same", output: .string("first")) + let second = ConversationItem( + id: "second", type: "function_call_output", callId: "same", output: .string("second")) + #expect(ChatTranscriptRow.rows([call, first, second]).map(\.id) == ["call", "first", "second"]) + #expect(ChatTranscriptRow.rows([first, call]).map(\.id) == ["first", "call"]) + #expect(ChatTranscriptRow.rows([first]).first?.toolResult == nil) + let otherCall = ConversationItem(id: "other-call", type: "function_call", callId: "same") + #expect(ChatTranscriptRow.rows([call, otherCall, first]).map(\.id) == ["call", "other-call", "first"]) + let emptyCall = ConversationItem(id: "empty-call", type: "function_call", callId: "") + let emptyResult = ConversationItem(id: "empty-result", type: "function_call_output", callId: "") + #expect(ChatTranscriptRow.rows([emptyCall, emptyResult]).count == 2) + } + + @Test func decodedConversationAndRetrievedResponseToolRepresentationsKeepIdenticalPairedRows() throws { + let decoder = JSONDecoder(); decoder.keyDecodingStrategy = .convertFromSnakeCase + let canonical = #""" + [ + {"id":"call","response_id":"response","type":"function_call","status":"completed", + "call_id":"same","name":"open_urls","arguments":"{\"urls\":[\"https://example.com\"]}"}, + {"id":"result","response_id":"response","type":"function_call_output","status":"completed", + "call_id":"same","output":"Synthetic opened page"}, + {"id":"web","type":"web_search_call","status":"completed","role":"assistant"} + ] + """# + let retrieved = canonical.replacingOccurrences(of: "function_call_output", with: "tool_output") + .replacingOccurrences(of: "function_call", with: "tool_call") + let pageItems = try decoder.decode([ConversationItem].self, from: Data(canonical.utf8)) + let responseItems = try decoder.decode([ConversationItem].self, from: Data(retrieved.utf8)) + let pageRows = ChatTranscriptRow.rows(pageItems), responseRows = ChatTranscriptRow.rows(responseItems) + #expect(pageRows.map(\.id) == ["call", "web"] && responseRows.map(\.id) == pageRows.map(\.id)) + #expect(responseRows[0].item.type == "tool_call" && responseRows[0].toolResult?.type == "tool_output") + #expect(responseRows[0].item.callId == pageRows[0].item.callId) + #expect(responseRows[0].item.arguments == pageRows[0].item.arguments) + #expect(responseRows[0].toolResult?.output == pageRows[0].toolResult?.output) + #expect(responseRows[0].item.responseId == "response" && responseRows[0].toolResult?.responseId == "response") + #expect(responseItems.allSatisfy(ChatTranscriptRow.isTool)) + var assistantCall = responseItems[0]; assistantCall.role = "assistant" + #expect(ChatTranscriptRow.isTool(assistantCall) && assistantCall.content == nil) + + // A duplicate call across the two representations is ambiguous. An + // unmatched persisted output also keeps its own visible details row. + #expect(ChatTranscriptRow.rows([pageItems[0], responseItems[0], responseItems[1]]).count == 3) + let orphan = ChatTranscriptRow.rows([responseItems[1]]) + #expect(orphan.count == 1 && orphan[0].item.type == "tool_output" && orphan[0].toolResult == nil) + } + + @Test func pendingIndicatorAppearsOnlyBeforeAssistantOutputDuringGeneration() { + let user = ConversationItem(id: "user", type: "message", role: "user") + let assistant = ConversationItem(id: "assistant", type: "message", status: "in_progress", role: "assistant") + let reasoning = ConversationItem(id: "reasoning", type: "reasoning") + let tool = ConversationItem(id: "tool", type: "web_search_call", status: "in_progress") + #expect(ChatTranscriptPresentation.showsPending(phase: .sending, items: [user])) + #expect(ChatTranscriptPresentation.showsPending(phase: .streaming, items: [user])) + for phase: ChatRunPhase? in [nil, .preparing, .stopping, .recovering] { + #expect(!ChatTranscriptPresentation.showsPending(phase: phase, items: [user])) + } + for output in [assistant, reasoning, tool] { + #expect(!ChatTranscriptPresentation.showsPending(phase: .streaming, items: [user, output])) + } + } + + @Test func reasoningDurationMatchesResearchRoundingAndWhitespaceRules() { + let english = Locale(identifier: "en") + #expect(ChatTranscriptPresentation.thoughtDuration("") == 1) + #expect(ChatTranscriptPresentation.thoughtDuration(" \n\t one word \n") == 1) + #expect( + ChatTranscriptPresentation.thoughtDuration(Array(repeating: "word", count: 38).joined(separator: " ")) == 1) + #expect( + ChatTranscriptPresentation.thoughtDuration(Array(repeating: "word", count: 39).joined(separator: "\t")) == 2 + ) + #expect(ChatTranscriptPresentation.thinkingLabel(text: "", streaming: true, locale: english) == "Thinking") + #expect( + ChatTranscriptPresentation.thinkingLabel(text: "one", streaming: false, locale: english) + == "Thought for 1 seconds") + #expect( + ChatTranscriptPresentation.thinkingLabel( + text: Array(repeating: "word", count: 52).joined(separator: " "), streaming: false, locale: english) + == "Thought for 2 seconds") + } + + @Test func usageLimitCopyMatchesMissingAndRawResearchProductNames() { + for productName: String? in [nil, "", "Free", "FREE"] { + #expect( + ChatRecoveryPresentation.message(.usageLimit, productName: productName) + == "You've reached your daily usage limit. Upgrade to Pro for more chats.") + } + for productName in ["Pro", "Pro Annual", "Team Pro"] { + #expect( + ChatRecoveryPresentation.message(.usageLimit, productName: productName) + == "You've reached your monthly Pro limit. Upgrade to Max for 10x more usage.") + } + for productName in ["Unrecognized plan", "Max", "Team", "Max Pro", " Free "] { + #expect( + ChatRecoveryPresentation.message(.usageLimit, productName: productName) + == "You've reached your monthly usage limit. Please wait for the next billing cycle.") + } + #expect( + ChatRecoveryPresentation.message(.contextLimit) == "Your message exceeds the context limit for this model.") + #expect( + ChatRecoveryPresentation.message(.freeTierTokenLimit) + == "This conversation is too long for the free tier. Upgrade to Pro for longer conversations.") + #expect( + ChatRecoveryPresentation.message(.modelUnavailable) + == "This model or feature is not available on your current plan.") + #expect( + ChatRecoveryPresentation.contextLimitMessage + == "Your message exceeds the context limit for the current model.\n\nHere's what you can try:\nShorten your message - Try reducing the amount of text or content" + ) + } + + @Test func composerPrivacyOnlyAppearsForEmptyUnassignedChat() { + #expect(ChatComposerPresentation.isEmptyState(hasMessages: false, conversationID: nil)) + #expect(!ChatComposerPresentation.isEmptyState(hasMessages: true, conversationID: nil)) + #expect(!ChatComposerPresentation.isEmptyState(hasMessages: false, conversationID: "existing")) + #expect(!ChatComposerPresentation.isEmptyState(hasMessages: true, conversationID: "existing")) + } + +} diff --git a/apps/maple-ios/Tests/MapleAppUITests/Composer/ComposerListContinuationTests.swift b/apps/maple-ios/Tests/MapleAppUITests/Composer/ComposerListContinuationTests.swift new file mode 100644 index 000000000..cb2d9951e --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppUITests/Composer/ComposerListContinuationTests.swift @@ -0,0 +1,115 @@ +import Foundation +import MapleAppCore +import Testing +@testable import MapleAppUI + +@Suite struct ComposerListContinuationTests { + private func edit(_ text: String, selection: NSRange? = nil) -> (text: String, selection: NSRange)? { + guard + let edit = ComposerListContinuation.edit( + text: text, selectionUTF16: selection ?? NSRange(location: text.utf16.count, length: 0)), + let output = DictationTextEdit( + beforeText: text, rangeUTF16: edit.rangeUTF16, replacement: edit.replacement + ).resultingText + else { return nil } + return (output, edit.selectionUTF16) + } + + @Test(arguments: ["-", "+", "*"]) + func unorderedMarkersContinueWithTheSameStyle(_ marker: String) { + let text = marker + " first item" + #expect(edit(text)?.text == text + "\n" + marker + " ") + #expect(edit(text)?.selection == NSRange(location: text.utf16.count + 3, length: 0)) + } + + @Test(arguments: [ + ("- [ ] todo", "- [ ] "), ("* [x] done", "* [ ] "), + ("\t+ [X]\tfinished", "\t+ [ ]\t"), + (" 9) ninth item", " 10) "), ("\t99. ninety-ninth", "\t100. "), + ("0009. leading zeroes", "10. "), + ("999999999999999999999. large", "1000000000000000000000. "), + ("- [x]not a task", "- "), + ]) + func indentationSpacingTasksAndDecimalMarkersMatchResearch(_ text: String, _ prefix: String) { + #expect(edit(text)?.text == text + "\n" + prefix) + #expect(edit(text)?.selection == NSRange(location: text.utf16.count + prefix.utf16.count + 1, length: 0)) + } + + @Test func theCurrentMixedListAndTextAfterTheCaretArePreserved() { + let mixed = "- first\n\n1. second\n\n* third" + #expect(edit(mixed)?.text == mixed + "\n* ") + #expect(edit("- item", selection: NSRange(location: 2, length: 0))?.text == "- \n- item") + #expect( + edit("- item", selection: NSRange(location: 2, length: 0))?.selection == NSRange(location: 5, length: 0)) + } + + @Test func selectedTextIncludingLaterLinesIsReplacedByOneExactNativeEdit() { + let text = "1. first selected\nsecond\nthird", source = "1. first" + let selection = NSRange(location: source.utf16.count, length: " selected\nsecond\n".utf16.count) + let continuation = ComposerListContinuation.edit(text: text, selectionUTF16: selection) + #expect(continuation?.rangeUTF16 == selection) + #expect(continuation?.replacement == "\n2. ") + #expect(edit(text, selection: selection)?.text == "1. first\n2. third") + #expect(edit(text, selection: selection)?.selection == NSRange(location: source.utf16.count + 4, length: 0)) + } + + @Test(arguments: ["First\n - ", "First\n\t3) ", "First\n * [x] "]) + func anEmptyItemRemovesItsMarkerAndRetainsIndentation(_ text: String) { + let line = text.components(separatedBy: "\n").last ?? "" + let indentation = String(line.prefix(while: { $0 == " " || $0 == "\t" })) + let expected = "First\n" + indentation + #expect(edit(text)?.text == expected) + #expect(edit(text)?.selection == NSRange(location: expected.utf16.count, length: 0)) + } + + @Test func anEmptyMarkerRemovesSelectedFollowingTextButPreservesUnselectedSuffix() { + let text = "- \nselected\nsuffix" + let selection = NSRange(location: 2, length: "\nselected".utf16.count) + #expect(edit(text, selection: selection)?.text == "\nsuffix") + #expect(edit(text, selection: selection)?.selection == NSRange(location: 0, length: 0)) + #expect(edit("- \nselected", selection: NSRange(location: 2, length: 9))?.text == "") + } + + @Test(arguments: [ + "ordinary text", "prefix - item", "\\- escaped", "-not a list", "- - -", "* * * *", + "```\n- code item", "~~~ts\n1. code item", "before\n````js\n* code item", "before\n~~~\n+ code item", + "~~~~\n1. code\n~~~\n2. still code", "```\n~~~\n- still code", "```\n``` trailing\n- still code", + ]) + func ordinaryTextHorizontalRulesAndOpenFencesDoNotContinue(_ text: String) { + #expect(edit(text) == nil) + } + + @Test func matchingOrLongerClosingFenceRestoresListContinuation() { + let text = "```\n- code\n````\n- real" + #expect(edit(text)?.text == text + "\n- ") + let tildes = " ~~~swift\n1. code\n ~~~\t\n1. real" + #expect(edit(tildes)?.text == tildes + "\n2. ") + } + + @Test(arguments: ["\r", "\u{2028}", "\u{2029}"]) + func trailingLineTerminatorsDoNotPartiallyMatchListMarkers(_ terminator: String) { + for prefix in ["- item", "- [x]", "3. item"] { + #expect(edit(prefix + terminator) == nil) + } + } + + @Test(arguments: ["\r", "\u{2028}", "\u{2029}"]) + func aClosingFenceMustMatchTheWholeLineLikeResearch(_ terminator: String) { + #expect(edit("```\ncode\n```" + terminator + "\n- still fenced") == nil) + } + + @Test func nativeRangesUseUTF16AndInvalidOrSplitSurrogatesFailClosed() { + let text = "Intro 🐝\n- café 🐝" + let emoji = (text as NSString).range(of: "🐝", options: .backwards) + #expect(edit(text)?.text == text + "\n- ") + #expect(edit(text)?.selection == NSRange(location: text.utf16.count + 3, length: 0)) + for range in [ + NSRange(location: NSNotFound, length: 0), NSRange(location: -1, length: 0), + NSRange(location: text.utf16.count + 1, length: 0), NSRange(location: 0, length: Int.max), + NSRange(location: emoji.location + 1, length: 0), + NSRange(location: emoji.location, length: 1), NSRange(location: emoji.location + 1, length: 1), + ] { + #expect(ComposerListContinuation.edit(text: text, selectionUTF16: range) == nil) + } + } +} diff --git a/apps/maple-ios/Tests/MapleAppUITests/EquationJavaScriptTests.swift b/apps/maple-ios/Tests/MapleAppUITests/EquationJavaScriptTests.swift new file mode 100644 index 000000000..731d12085 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppUITests/EquationJavaScriptTests.swift @@ -0,0 +1,77 @@ +#if os(macOS) + import Foundation + import JavaScriptCore + import Testing + @testable import MapleAppUI + + @MainActor + @Suite struct EquationJavaScriptTests { + private func context() throws -> JSContext { + let url = try #require(EquationResourcePolicy.resource("katex.min.js")) + let script = try String(contentsOf: url, encoding: .utf8) + let context = try #require(JSContext()) + context.evaluateScript(script) + #expect(context.exception == nil) + return context + } + + private func render(_ formula: String, display: Bool = false, context: JSContext) -> String? { + context.setObject(formula, forKeyedSubscript: "input" as NSString) + context.setObject(display, forKeyedSubscript: "display" as NSString) + context.exception = nil + let result = context.evaluateScript( + """ + katex.renderToString(input, { displayMode: display, output: 'html', + throwOnError: true, trust: false, strict: 'error', maxExpand: 1000, + maxSize: 16, macros: Object.create(null), globalGroup: false }) + """) + return context.exception == nil ? result?.toString() : nil + } + + @Test func pinnedRendererSupportsNativeCandidateGaps() throws { + let js = try context() + for source in [ + #"\operatorname{argmax}_{x} f(x)"#, #"\Bigl(\frac{a}{b}\Bigr)"#, + #"\boxed{E=mc^2}"#, #"\newcommand{\RR}{\mathbb{R}}\RR^2"#, + #"E=mc^2\tag{1}"#, + ] { + let output = try #require(render(source, display: true, context: js)) + #expect(output.contains("katex")) + #expect(!output.contains("katex-error")) + } + } + + @Test func trustCommandsCannotCreateLinksImagesOrHTML() throws { + let js = try context() + for source in [ + #"\href{https://example.test/private}{x}"#, + #"\url{https://example.test/private}"#, + #"\includegraphics{https://example.test/private}"#, + #"\htmlStyle{background:url(https://example.test/private)}{x}"#, + #"\htmlClass{private}{x}"#, + ] { + if let output = render(source, context: js) { + #expect(!output.contains("}"#, context: js) { + #expect(!output.contains("")) + #expect(!output.contains(" 240 && pixel[2] < 10) + } + + @Test func encodedAxisLimitIsCheckedBeforeThumbnailAllocation() throws { + let data = try fixture(type: .png, width: 100_001, height: 1) + #expect(throws: ImageImportIssue.tooLarge) { + try ImagePreviewDecoding.thumbnail(data, edge: 84) + } + } + + @Test( + arguments: [ + (0.0, 1.0), (-1, 20), (1, Double.infinity), (Double.nan, 1), + (100_001, 1), (1, 100_001), (10_001, 10_000), + ] as [(Double, Double)]) + func untrustedDimensionsCannotAllocate(width: Double, height: Double) { + #expect(throws: ImageImportIssue.tooLarge) { + try ImagePreviewDecoding.validateDimensions(width: width, height: height) + } + } + + @Test func acceptedDimensionBoundaryDoesNotOverflow() throws { + try ImagePreviewDecoding.validateDimensions(width: 100_000, height: 1_000) + try ImagePreviewDecoding.validateDimensions(width: 1_000, height: 100_000) + } + + @Test func incompleteAndOversizedHistoryAreRejected() throws { + let png = try fixture(type: .png, width: 40, height: 20) + #expect(throws: ImageImportIssue.invalidImage) { + try ImagePreviewDecoding.thumbnail(Data(png.prefix(png.count / 2)), edge: 84) + } + #expect(throws: ImageImportIssue.tooLarge) { + try ImagePreviewDecoding.thumbnail( + Data(count: ImageImportProcessor.maximumInputBytes + 1), edge: 84) + } + } + + private func fixture(type: UTType, width: Int, height: Int, frames: Int = 1) throws -> Data { + let context = try #require( + CGContext( + data: nil, width: width, height: height, bitsPerComponent: 8, + bytesPerRow: width * 4, space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)) + let output = NSMutableData() + let destination = try #require( + CGImageDestinationCreateWithData(output, type.identifier as CFString, frames, nil)) + for frame in 0.. TeamStatus { + let decoder = JSONDecoder(); decoder.keyDecodingStrategy = .convertFromSnakeCase + return try decoder.decode(TeamStatus.self, from: JSONSerialization.data(withJSONObject: fields)) + } + #expect(LibraryAccountAttention(team: nil) == nil) + #expect( + try LibraryAccountAttention(team: team(["has_team_subscription": false, "team_created": false])) == nil) + #expect( + try LibraryAccountAttention(team: team(["has_team_subscription": true, "team_created": false])) + == .setupRequired) + #expect( + try LibraryAccountAttention( + team: team([ + "has_team_subscription": true, "team_created": true, "team_member_count": 3, "billed_seat_count": 2, + ])) == .usagePaused) + #expect( + try LibraryAccountAttention( + team: team([ + "has_team_subscription": true, "team_created": true, "seat_limit_exceeded": true, + ])) == .usagePaused) + #expect( + try LibraryAccountAttention( + team: team([ + "has_team_subscription": true, "team_created": true, "team_member_count": 2, "billed_seat_count": 2, + ])) == nil) + } +} diff --git a/apps/maple-ios/Tests/MapleAppUITests/MessageCorpusTests.swift b/apps/maple-ios/Tests/MapleAppUITests/MessageCorpusTests.swift new file mode 100644 index 000000000..c943e32fe --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppUITests/MessageCorpusTests.swift @@ -0,0 +1,50 @@ +#if DEBUG + import Foundation + import Testing + @testable import MapleAppUI + @testable import Textual + + @Suite struct MessageCorpusTests { + @Test func fullNativeCorpusKeepsOnlyTheExplicitFenceAsCode() async throws { + let prepared = try await MessageDocumentWorker().prepare(RendererAcceptanceCorpus.markdown) + #expect(prepared.codeBlocks.count == 1) + #expect(prepared.codeBlocks.values.first?.block.language == "swift") + let text = Textual.Formatter(prepared.content).plainText() + #expect(text.contains("Indented ordinary prose in the Research baseline.")) + #expect(text.contains("Soft first\nSoft second\n\nHard first\nHard second")) + } + + @Test(arguments: ["\r\n", "\r"]) + func originalSourceLineLookupHandlesCommonMarkNewlines(_ newline: String) throws { + let source = RendererAcceptanceCorpus.markdown.replacingOccurrences(of: "\n", with: newline) + let parsed = try AttributedString( + markdown: source, options: .init(interpretedSyntax: .full, appliesSourcePositionAttributes: true)) + let content = try MessageMarkdownParity.normalize(parsed, source: source) + let codeRuns = content.runs.filter { run in + run.presentationIntent?.components.contains { component in + if case .codeBlock = component.kind { return true } + return false + } == true + } + #expect(codeRuns.count == 1) + #expect(!codeRuns.contains { String(content[$0.range].characters).contains("Indented ordinary prose") }) + #expect( + Textual.Formatter(content).plainText().contains("Indented ordinary prose in the Research baseline.")) + } + + @Test func plainCopySurvivesTheNativeAttributedStringBridge() async throws { + let prepared = try await MessageDocumentWorker().prepare( + "Soft first\nSoft second\n\nHard first \nHard second") + let native = try NSAttributedString(prepared.content, including: \.textual) + #expect(Textual.Formatter(native).plainText() == "Soft first\nSoft second\n\nHard first\nHard second") + } + + @Test func originalEquationAccessibilitySurvivesUnresolvedRasterization() async throws { + let worker = MessageDocumentWorker() + let prepared = try await worker.prepare(#"Original \(\boxed{x}\) and $y^2$."#) + let original = try await worker.resolve(prepared, equations: [:]) + #expect(original.equationSources == [#"\(\boxed{x}\)"#, "$y^2$"]) + #expect(original.content.characters.contains("x")) + } + } +#endif diff --git a/apps/maple-ios/Tests/MapleAppUITests/MessageDocumentTests.swift b/apps/maple-ios/Tests/MapleAppUITests/MessageDocumentTests.swift new file mode 100644 index 000000000..d54a9bd1a --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppUITests/MessageDocumentTests.swift @@ -0,0 +1,87 @@ +import Foundation +import Testing +import SwiftUI +import Textual +@testable import MapleAppUI + +@Suite struct MessageDocumentTests { + @Test func equationEnrichmentChangesPresentationKeyWithoutRandomRefreshes() async throws { + let worker = MessageDocumentWorker() + let draft = try await worker.prepare("Equation $x$.") + let id = try #require(draft.formulas.first?.id) + let plain = try await worker.resolve(draft, equations: [:]) + let equations = [id: AnyAttachment(TestEquation())] + let rendered = try await worker.resolve(draft, equations: equations) + let repeated = try await worker.resolve(draft, equations: equations) + #expect(plain.revision == rendered.revision) + #expect(plain.presentationKey != rendered.presentationKey) + #expect(rendered.presentationKey == repeated.presentationKey) + #expect(rendered.content.runs.contains { $0.textual.attachment?.description == "$x$" }) + } + + @Test func incompleteTeXKeepsBackslashesAfterMarkdownParsing() async throws { + for source in [#"Text \(\frac{1}{"#, #"Text \[\alpha +"#, #"Text $\frac{1}{"#] { + let worker = MessageDocumentWorker() + let draft = try await worker.prepare(source) + let result = try await worker.resolve(draft, equations: [:]) + #expect(String(result.content.characters) == source) + #expect(result.formulas.isEmpty) + } + } + + @Test func quotedAndNestedFencedCodeNeverReachesEquationRenderer() async throws { + for source in [ + "> ~~~math\n> $\\boxed{x}$\n> ~~~", "- code\n\n ```math\n $x$\n ```", + ] { + let result = try await MessageDocumentWorker().prepare(source) + #expect(result.formulas.isEmpty) + #expect(!result.codeBlocks.isEmpty) + for run in result.content.runs { + for intent in run.presentationIntent?.components ?? [] { + if case .codeBlock(let hint) = intent.kind { + let key = try #require(hint) + #expect(result.codeBlocks[key] != nil) + #expect(key != "math") + } + } + } + #expect(!String(result.content.characters).contains("\u{E000}")) + } + } + + @Test func URLsRemainIntactAndUnsafeSchemesAndImagesHaveNoCapability() async throws { + let source = + #"[safe](https://example.test/$x$/path) [bad](javascript:alert%281%29) [private](https://user:password@example.test) ![alt](https://example.test/image)"# + let result = try await MessageDocumentWorker().prepare(source) + #expect(result.formulas.isEmpty) + let links = result.content.runs.compactMap(\.link) + #expect(links.map(\.absoluteString) == ["https://example.test/$x$/path"]) + #expect(result.content.runs.allSatisfy { $0.imageURL == nil }) + } + + @Test func gfmKeepsTableListAndStrikethroughStructure() async throws { + let result = try await MessageDocumentWorker().prepare( + "~~strike~~\n\n- item\n\n| A | B |\n| --- | --- |\n| one | two |") + #expect(result.content.runs.contains { $0.inlinePresentationIntent?.contains(.strikethrough) == true }) + #expect( + result.content.runs.contains { run in + run.presentationIntent?.components.contains { + if case .table = $0.kind { return true }; return false + } == true + }) + #expect( + result.content.runs.contains { run in + run.presentationIntent?.components.contains { + if case .unorderedList = $0.kind { return true }; return false + } == true + }) + } +} + +private struct TestEquation: Textual.Attachment { + var description: String { "$x$" } + var body: some View { Text("x") } + func sizeThatFits(_ proposal: ProposedViewSize, in environment: TextEnvironmentValues) -> CGSize { + CGSize(width: 10, height: 10) + } +} diff --git a/apps/maple-ios/Tests/MapleAppUITests/MessageMarkdownParityTests.swift b/apps/maple-ios/Tests/MapleAppUITests/MessageMarkdownParityTests.swift new file mode 100644 index 000000000..4fbb5564d --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppUITests/MessageMarkdownParityTests.swift @@ -0,0 +1,106 @@ +import Foundation +import Testing +@testable import MapleAppUI +@testable import Textual + +@Suite struct MessageMarkdownParityTests { + @Test func nativeCopyKeepsExplicitHardBreaksInPlainTextAndHTML() throws { + let content = try AttributedString(markdown: "Hard first \nHard second") + let formatter = Textual.Formatter(content) + #expect(formatter.plainText() == "Hard first\nHard second") + #expect(formatter.html() == "

Hard first
Hard second

") + } + + @Test func researchSoftBreaksRenderAndExportAsActualLineBreaks() async throws { + let prepared = try await MessageDocumentWorker().prepare("Soft first\nSoft second\n\nHard first \nHard second") + let formatter = Textual.Formatter(prepared.content) + #expect(formatter.plainText() == "Soft first\nSoft second\n\nHard first\nHard second") + #expect(formatter.html() == "

Soft first
Soft second

\n

Hard first
Hard second

") + } + + @Test func partialNativeSelectionRetainsBreaksAndInlineStyles() async throws { + let prepared = try await MessageDocumentWorker().prepare("prefix **bold first**\n*italic second* suffix") + let start = try #require(prepared.content.range(of: "first")?.lowerBound) + let end = try #require(prepared.content.range(of: "second")?.upperBound) + let formatter = Textual.Formatter(AttributedString(prepared.content[start..")) + #expect(formatter.html().contains("first")) + #expect(formatter.html().contains("italic second")) + } + + @Test func indentedOrdinaryProseUsesInlineFormattingNotCode() async throws { + let result = try await MessageDocumentWorker().prepare(" **Indented** ordinary\n second line") + #expect(result.codeBlocks.isEmpty) + #expect(result.content.runs.contains { $0.inlinePresentationIntent?.contains(.stronglyEmphasized) == true }) + #expect(Textual.Formatter(result.content).plainText() == "Indented ordinary\nsecond line") + #expect(!Textual.Formatter(result.content).html().contains("
"))
+    }
+
+    @Test func indentedProseCanContainMathAndCannotIntroduceUnsafeLinks() async throws {
+        let result = try await MessageDocumentWorker().prepare("    $x$ [unsafe](javascript:alert%281%29)")
+        #expect(result.codeBlocks.isEmpty)
+        #expect(result.formulas.count == 1)
+        #expect(result.content.runs.allSatisfy { $0.link == nil && $0.imageURL == nil })
+    }
+
+    @Test(arguments: [
+        "```\n**literal**\n    keep indentation\n```",
+        "> ```\n> **literal**\n>     keep indentation\n> ```",
+        "- item\n\n    ```\n    **literal**\n        keep indentation\n    ```",
+        "> ~~~math\n> $\\boxed{x}$\n> ~~~",
+    ])
+    func fencedCodeRetainsLiteralSourceAndNeverEntersEquationPath(_ source: String) async throws {
+        let result = try await MessageDocumentWorker().prepare(source)
+        #expect(!result.codeBlocks.isEmpty)
+        #expect(result.formulas.isEmpty)
+        #expect(!result.content.runs.contains { $0.inlinePresentationIntent?.contains(.stronglyEmphasized) == true })
+        if source.contains("keep indentation") {
+            #expect(result.codeBlocks.values.contains { $0.block.source.contains("    keep indentation") })
+        }
+    }
+
+    @Test func realTaskMarkersHaveCheckedStateWhileInlineCodeRemainsLiteral() async throws {
+        let result = try await MessageDocumentWorker().prepare(
+            "- [ ] Todo\n- [x] Done\n- [X]\tDone too\n- `[ ]` code\n\n[ ] ordinary")
+        let text = Textual.Formatter(result.content).plainText()
+        #expect(text.contains("☐ Todo"))
+        #expect(text.contains("☑ Done"))
+        #expect(text.contains("☑\tDone too"))
+        #expect(text.contains("[ ] code"))
+        #expect(text.contains("[ ] ordinary"))
+    }
+
+    @Test func ASTTaskRecognitionExcludesEscapedMarkersAndInlineCode() throws {
+        // Test the Markdown AST contract directly: in a whole message, \[ is
+        // also an explicit TeX opener and its incomplete suffix stays literal.
+        let source = "- \\[ ] Escaped\n- `[ ]` code"
+        let parsed = try AttributedString(
+            markdown: source, options: .init(interpretedSyntax: .full, appliesSourcePositionAttributes: true))
+        let content = try MessageMarkdownParity.normalize(parsed, source: source)
+        let text = Textual.Formatter(content).plainText()
+        #expect(text.contains("[ ] Escaped"))
+        #expect(text.contains("[ ] code"))
+        #expect(!text.contains("☐"))
+    }
+
+    @Test func multilineInlineCodeAndLinkDestinationsAreUnmodified() async throws {
+        let source = "`first\nsecond` [safe](https://example.test/$x$/path?q=first%0Asecond)"
+        let foundation = try AttributedString(markdown: source)
+        let result = try await MessageDocumentWorker().prepare(source)
+        let before = foundation.runs.filter { $0.inlinePresentationIntent?.contains(.code) == true }
+            .map { String(foundation[$0.range].characters) }
+        let after = result.content.runs.filter { $0.inlinePresentationIntent?.contains(.code) == true }
+            .map { String(result.content[$0.range].characters) }
+        #expect(after == before)
+        #expect(
+            result.content.runs.compactMap(\.link).map(\.absoluteString)
+                == ["https://example.test/$x$/path?q=first%0Asecond"])
+        #expect(result.formulas.isEmpty)
+    }
+
+    @Test func indentedLiteralFenceDoesNotBecomeAProtectedFencedBlock() async throws {
+        let result = try await MessageDocumentWorker().prepare("    ```\n    ordinary\n    ```")
+        #expect(result.codeBlocks.isEmpty)
+    }
+}
diff --git a/apps/maple-ios/Tests/MapleAppUITests/MessageMarkupTests.swift b/apps/maple-ios/Tests/MapleAppUITests/MessageMarkupTests.swift
new file mode 100644
index 000000000..da8c9dbc5
--- /dev/null
+++ b/apps/maple-ios/Tests/MapleAppUITests/MessageMarkupTests.swift
@@ -0,0 +1,98 @@
+import Foundation
+import Testing
+@testable import MapleAppUI
+
+@Suite struct MessageMarkupTests {
+    @Test func extractsMathWithoutLosingBackslashesOrOriginalDelimiters() async throws {
+        let source = #"Energy $E=mc^2$ and \(\{x\in\mathbb{R}\}\)."#
+        let result = try await MessageMarkupParser().parse(source)
+        #expect(result.original == source)
+        #expect(result.formulas.map(\.latex) == [#"E=mc^2"#, #"\{x\in\mathbb{R}\}"#])
+        #expect(result.formulas.map(\.original) == [#"$E=mc^2$"#, #"\(\{x\in\mathbb{R}\}\)"#])
+    }
+
+    @Test func currencyAndNumericInlinePreserveCurrentBehavior() async throws {
+        let result = try await MessageMarkupParser().parse(#"Pay $12.50 or $7; $1+2=3$ stays literal, $x+y$ is math."#)
+        #expect(result.formulas.map(\.latex) == ["x+y"])
+        #expect(result.markdown.contains("$12.50"))
+        #expect(result.markdown.contains("$1+2=3$"))
+    }
+
+    @Test func codeNeverEntersMathPathAndMathFencesUseOwnedCodeRenderer() async throws {
+        let source = "`$x$`\n\n```math\n\\boxed{x}\n```\n\n~~~swift\nlet price = \"$5\"\n~~~\n$x+y$"
+        let result = try await MessageMarkupParser().parse(source)
+        #expect(result.formulas.map(\.latex) == ["x+y"])
+        #expect(result.codeBlocks["maplecode0"]?.language == "math")
+        #expect(result.codeBlocks["maplecode0"]?.source == #"\boxed{x}"#)
+        #expect(!result.markdown.contains("```math"))
+        #expect(result.markdown.contains("`$x$`"))
+    }
+
+    @Test func incompleteStreamRemainsVisible() async throws {
+        for source in [#"Text \(\frac{1}{"#, #"Text $\frac{1}{"#, "`unfinished $x$"] {
+            let result = try await MessageMarkupParser().parse(source)
+            #expect(result.formulas.isEmpty)
+            #expect(result.markdown == source || result.literals.first?.source == String(source.dropFirst(5)))
+        }
+    }
+
+    @Test func unclosedFenceRetainsCompleteCode() async throws {
+        let result = try await MessageMarkupParser().parse("```swift\nlet x = 1\n$x$")
+        #expect(result.formulas.isEmpty)
+        #expect(result.codeBlocks["maplecode0"]?.source == "let x = 1\n$x$")
+    }
+
+    @Test func formulaBudgetRejectsDeepAndMalformedInputWithoutDroppingSource() async throws {
+        var limits = MessageRenderLimits(); limits.formulaDepth = 2
+        let result = try await MessageMarkupParser().parse("${{{x}}}$ $\\frac{1}{$", limits: limits)
+        #expect(result.formulas.count == 2)
+        #expect(result.formulas.allSatisfy { !$0.isWithinBudget })
+        #expect(result.formulas[0].original == "${{{x}}}$")
+    }
+
+    @Test func formattingLimitKeepsEntireMessage() async throws {
+        var limits = MessageRenderLimits(); limits.documentBytes = 8
+        let source = "Message 👩🏽‍💻 with more than eight bytes"
+        let result = try await MessageMarkupParser().parse(source, limits: limits)
+        #expect(result.isPlainText)
+        #expect(result.original == source && result.markdown == source)
+    }
+
+    @Test func reservedPlaceholderCannotBecomeAnAttachment() async throws {
+        let source = "\u{E000}maple-equation-0\u{E001} $x$"
+        let result = try await MessageMarkupParser().parse(source)
+        #expect(result.isPlainText && result.formulas.isEmpty)
+        #expect(result.markdown == source)
+    }
+
+    @Test func displayMathAndEscapedCurrencyHaveDistinctTokens() async throws {
+        let result = try await MessageMarkupParser().parse(#"Cost \$5. Display \[x^2\] and $$y^2$$."#)
+        #expect(result.formulas.map(\.display) == [true, true])
+        #expect(result.formulas.map(\.latex) == ["x^2", "y^2"])
+    }
+
+    @Test func formulaCountLimitKeepsUnformattedRemainder() async throws {
+        var limits = MessageRenderLimits(); limits.formulaCount = 1
+        let result = try await MessageMarkupParser().parse("$x$ then $y$", limits: limits)
+        #expect(result.formulas.count == 1)
+        #expect(result.literals.first?.source == "$y$")
+    }
+
+    @Test func highlighterPreservesEveryUnicodeCharacter() {
+        let source = "// العربية 中文 👩🏽‍💻\nlet value = \"é\\\"\"; return 42\n"
+        let result = MessageCodeHighlighter.tokens(source, language: "swift")
+        #expect(result.map(\.text).joined() == source)
+        #expect(result.contains { $0.kind == .keyword && $0.text == "let" })
+        #expect(result.contains { $0.kind == .number && $0.text == "42" })
+    }
+
+    @Test func highlighterBoundsAndUnknownLanguageUseCompletePlainSource() {
+        let source = String(repeating: "/#-", count: 5000)
+        #expect(MessageCodeHighlighter.tokens(source, language: "unknown") == [.init(text: source, kind: .plain)])
+        #expect(
+            MessageCodeHighlighter.tokens(source, language: "swift", byteLimit: 100) == [
+                .init(text: source, kind: .plain)
+            ])
+        #expect(MessageCodeHighlighter.tokens(source, language: "swift") == [.init(text: source, kind: .plain)])
+    }
+}
diff --git a/apps/maple-ios/Tests/MapleAppUITests/MessageRenderModelTests.swift b/apps/maple-ios/Tests/MapleAppUITests/MessageRenderModelTests.swift
new file mode 100644
index 000000000..ddc66553d
--- /dev/null
+++ b/apps/maple-ios/Tests/MapleAppUITests/MessageRenderModelTests.swift
@@ -0,0 +1,264 @@
+import Foundation
+import Testing
+import SwiftUI
+import Textual
+@testable import MapleAppUI
+
+@MainActor
+@Suite struct MessageRenderModelTests {
+    @Test func continuousChunksDoNotRestartPublicationDeadline() async throws {
+        let clock = RenderTestClock()
+        let model = MessageRenderModel(pause: { _ in try await clock.pause() })
+        defer { model.cancel() }
+        let owner = UUID()
+        model.submit(text: "first", generation: owner)
+        try await eventually { await clock.count == 1 }
+        for index in 0..<100 { model.submit(text: "chunk \(index)", generation: owner) }
+        #expect(await clock.count == 1)
+        await clock.tick()
+        try await eventually { model.prepared?.revision == "chunk 99" }
+        #expect(String(model.prepared!.content.characters) == "chunk 99")
+    }
+
+    @Test func latePreparationCannotCrossGenerationOrCancellation() async throws {
+        let clock = RenderTestClock(), worker = SuspendedPreparation()
+        let model = MessageRenderModel(worker: worker, pause: { _ in try await clock.pause() })
+        defer { model.cancel() }
+        model.submit(text: "old account", generation: UUID())
+        try await eventually { await clock.count == 1 }
+        await clock.tick()
+        try await eventually { await worker.contains("old account") }
+        let next = UUID()
+        model.submit(text: "new account", generation: next)
+        #expect(model.prepared == nil)
+        try await eventually { await clock.count == 1 }
+        await clock.tick()
+        try await eventually { await worker.contains("new account") }
+        await worker.finish("old account")
+        await Task.yield()
+        #expect(model.prepared == nil)
+        await worker.finish("new account")
+        try await eventually { model.prepared?.revision == "new account" }
+        #expect(model.generation == next)
+        model.cancel()
+        #expect(model.prepared == nil)
+    }
+
+    @Test func completedEquationSurvivesSuspensionButNotGenerationChange() async throws {
+        let clock = RenderTestClock()
+        let model = MessageRenderModel(
+            pause: { _ in try await clock.pause() },
+            equations: { formulas in
+                Dictionary(uniqueKeysWithValues: formulas.map { ($0.id, AnyAttachment(RetainedTestEquation())) })
+            })
+        defer { model.cancel() }
+        let owner = UUID(), source = "Equation $x$."
+        model.submit(text: source, generation: owner)
+        try await eventually { await clock.count == 1 }
+        await clock.tick()
+        try await eventually { await clock.count == 1 && model.prepared != nil }
+        await clock.tick()
+        try await eventually {
+            model.prepared?.content.runs.contains { $0.textual.attachment?.description == "$x$" } == true
+        }
+        let projection = try #require(model.prepared?.presentationKey)
+        model.suspend()
+        #expect(model.prepared?.presentationKey == projection)
+        model.submit(text: source, generation: owner)
+        await Task.yield()
+        #expect(await clock.count == 0)
+        #expect(model.prepared?.presentationKey == projection)
+        model.submit(text: source, generation: UUID())
+        #expect(model.prepared == nil)
+    }
+
+    @Test func completedHistoryRenderingSurvivesRepeatedOwnedScrollSuspension() async throws {
+        let clock = RenderTestClock(), worker = CountingMessagePreparation()
+        let model = MessageRenderModel(worker: worker, pause: { _ in try await clock.pause() })
+        defer { model.cancel() }
+        let owner = UUID()
+        let source = """
+            # Completed history reply
+
+            A **rich reply** with [a source](https://example.com/reference).
+
+            - First item
+            - Second item
+
+            ```swift
+            let historical = true
+            ```
+            """
+        model.submit(text: source, generation: owner)
+        try await eventually { await clock.count == 1 }
+        await clock.tick()
+        try await eventually { model.prepared?.revision == source }
+        let prepared = try #require(model.prepared)
+        #expect(!prepared.codeBlocks.isEmpty)
+        #expect(!String(prepared.content.characters).contains("**"))
+        #expect(await worker.preparationCount == 1)
+
+        // Lazy history rows repeatedly leave and re-enter the visible viewport
+        // while retaining the same conversation and account-generation owner.
+        for _ in 0..<8 {
+            model.suspend()
+            #expect(model.prepared?.presentationKey == prepared.presentationKey)
+            model.submit(text: source, generation: owner)
+            await Task.yield()
+            #expect(model.prepared?.presentationKey == prepared.presentationKey)
+            #expect(model.generation == owner)
+            #expect(await clock.count == 0, "Returning to completed history must not restart the throttle")
+            #expect(await worker.preparationCount == 1, "Returning to completed history must not parse again")
+        }
+
+        let replacement = UUID()
+        model.submit(text: source, generation: replacement)
+        #expect(model.prepared == nil, "The same source cannot reuse a retired generation's projection")
+        try await eventually { await clock.count == 1 }
+        await clock.tick()
+        try await eventually { model.prepared?.revision == source }
+        #expect(model.generation == replacement)
+        #expect(await worker.preparationCount == 2)
+        model.cancel()
+        #expect(model.prepared == nil, "Conversation retirement discards retained history rendering")
+    }
+
+    @Test func suspensionRejectsLatePreparationAndRestartsTheSameIncompleteRequest() async throws {
+        let clock = RenderTestClock(), worker = SuspendedPreparation()
+        let model = MessageRenderModel(worker: worker, pause: { _ in try await clock.pause() })
+        defer { model.cancel() }
+        let owner = UUID(), source = "Incomplete owned message", newest = "Latest owned message"
+        model.submit(text: source, generation: owner)
+        try await eventually { await clock.count == 1 }
+        await clock.tick()
+        try await eventually { await worker.contains(source) }
+        model.suspend()
+        model.submit(text: source, generation: owner)
+        try await eventually { await clock.count == 1 }
+        await clock.tick()
+        try await eventually { await worker.count(source) == 2 }
+        await worker.finish(source)
+        try await eventually { await worker.resolutionCount(source) == 1 }
+        await Task.yield()
+        #expect(model.prepared == nil)
+        model.submit(text: newest, generation: owner)
+        await Task.yield()
+        #expect(await clock.count == 0, "The old drain must not clear the replacement task")
+        await worker.finish(source)
+        try await eventually { await clock.count == 1 }
+        await clock.tick()
+        try await eventually { await worker.contains(newest) }
+        await worker.finish(newest)
+        try await eventually { model.prepared?.revision == newest }
+        #expect(model.generation == owner)
+    }
+
+    @Test func replacingARequestCannotReuseItsOldCompletionAfterReturningToTheSameText() async throws {
+        let clock = RenderTestClock(), worker = SuspendedPreparation()
+        let model = MessageRenderModel(worker: worker, pause: { _ in try await clock.pause() })
+        defer { model.cancel() }
+        let owner = UUID()
+        model.submit(text: "A", generation: owner)
+        try await eventually { await clock.count == 1 }
+        await clock.tick()
+        try await eventually { await worker.contains("A") }
+        await worker.finish("A")
+        try await eventually { model.prepared?.revision == "A" }
+        model.submit(text: "B", generation: owner)
+        try await eventually { await clock.count == 1 }
+        await clock.tick()
+        try await eventually { await worker.contains("B") }
+        model.submit(text: "A", generation: owner)
+        await worker.finish("B")
+        try await eventually {
+            let waiting = await clock.count
+            return model.prepared?.revision == "B" && waiting == 1
+        }
+        model.suspend()
+        #expect(model.prepared == nil, "Incomplete B cannot inherit the old completed A marker")
+        try await eventually { await clock.count == 0 }
+        model.submit(text: "A", generation: owner)
+        try await eventually { await clock.count == 1 }
+        await clock.tick()
+        try await eventually { await worker.contains("A") }
+        await worker.finish("A")
+        try await eventually { model.prepared?.revision == "A" }
+    }
+
+    private func eventually(_ condition: () async -> Bool) async throws {
+        for _ in 0..<300 {
+            if await condition() { return }
+            try await Task.sleep(for: .milliseconds(10))
+        }
+        Issue.record("Controlled renderer task did not reach its expected state")
+        throw CancellationError()
+    }
+}
+
+private actor RenderTestClock {
+    private var sleepers: [(UUID, CheckedContinuation)] = []
+    var count: Int { sleepers.count }
+    func pause() async throws {
+        let id = UUID()
+        try await withTaskCancellationHandler {
+            try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in
+                if Task.isCancelled {
+                    continuation.resume(throwing: CancellationError())
+                } else {
+                    sleepers.append((id, continuation))
+                }
+            }
+        } onCancel: {
+            Task { await self.cancel(id) }
+        }
+    }
+    func tick() { if !sleepers.isEmpty { sleepers.removeFirst().1.resume() } }
+    private func cancel(_ id: UUID) {
+        guard let index = sleepers.firstIndex(where: { $0.0 == id }) else { return }
+        sleepers.remove(at: index).1.resume(throwing: CancellationError())
+    }
+}
+
+private actor SuspendedPreparation: MessagePreparation {
+    private var pending: [String: [CheckedContinuation]] = [:]
+    private var resolutions: [String: Int] = [:]
+    func prepare(_ source: String) async throws -> PreparedMessage {
+        await withCheckedContinuation { pending[source, default: []].append($0) }
+    }
+    func resolve(_ draft: PreparedMessage, equations: [String: AnyAttachment]) async throws -> PreparedMessage {
+        resolutions[draft.revision, default: 0] += 1
+        return draft
+    }
+    func contains(_ source: String) -> Bool { count(source) > 0 }
+    func count(_ source: String) -> Int { pending[source]?.count ?? 0 }
+    func resolutionCount(_ source: String) -> Int { resolutions[source] ?? 0 }
+    func finish(_ source: String) {
+        guard var requests = pending[source], !requests.isEmpty else { return }
+        let continuation = requests.removeFirst()
+        pending[source] = requests.isEmpty ? nil : requests
+        continuation.resume(
+            returning: .init(revision: source, content: AttributedString(source), codeBlocks: [:], formulas: []))
+    }
+}
+
+private actor CountingMessagePreparation: MessagePreparation {
+    private let worker = MessageDocumentWorker()
+    private(set) var preparationCount = 0
+
+    func prepare(_ source: String) async throws -> PreparedMessage {
+        preparationCount += 1
+        return try await worker.prepare(source)
+    }
+
+    func resolve(_ draft: PreparedMessage, equations: [String: AnyAttachment]) async throws -> PreparedMessage {
+        try await worker.resolve(draft, equations: equations)
+    }
+}
+
+private struct RetainedTestEquation: Textual.Attachment {
+    var description: String { "$x$" }
+    var body: some View { Text("x") }
+    func sizeThatFits(_ proposal: ProposedViewSize, in environment: TextEnvironmentValues) -> CGSize {
+        CGSize(width: 10, height: 10)
+    }
+}
diff --git a/apps/maple-ios/Tests/MapleAppUITests/NativeCopyBoundaryProbeTests.swift b/apps/maple-ios/Tests/MapleAppUITests/NativeCopyBoundaryProbeTests.swift
new file mode 100644
index 000000000..1c318ece9
--- /dev/null
+++ b/apps/maple-ios/Tests/MapleAppUITests/NativeCopyBoundaryProbeTests.swift
@@ -0,0 +1,92 @@
+#if DEBUG
+    import Foundation
+    import Testing
+    @testable import Textual
+
+    #if canImport(AppKit)
+        import AppKit
+    #endif
+
+    /// Input reconstructed from the six public attributed runs in native selection.
+    /// The extra native key keeps six input runs distinct; Formatter ignores that key.
+    @Suite struct NativeCopyBoundaryTests {
+        @Test func observedNativeRunsPreservePlainAndHTMLLineBreaks() throws {
+            let native = try Self.observedSelection()
+            var runs = 0
+            native.enumerateAttributes(in: NSRange(location: 0, length: native.length)) { _, _, _ in
+                runs += 1
+            }
+            let bridged = try AttributedString(native, including: \.textual)
+            #expect(runs == 6)
+            #expect(native.length == 44)
+            #expect(native.string.filter { $0 == "\n" }.count == 2)
+            #expect(bridged.runs.allSatisfy { ($0.inlinePresentationIntent ?? []).isEmpty })
+            let formatter = Textual.Formatter(native)
+            #expect(formatter.plainText() == "Soft first\nSoft second\n\nHard first\nHard second")
+            #expect(formatter.html() == "

Soft first
Soft second

\n

Hard first
Hard second

") + } + + @Test func literalBreaksDoNotUnescapeUserEntitiesOrUnicode() { + var content = AttributedString("&<> \nCafé 👩🏽‍💻 e\u{301}") + content.presentationIntent = PresentationIntent(.paragraph, identity: 1) + let formatter = Textual.Formatter(content) + #expect(formatter.plainText() == "&<> \nCafé 👩🏽‍💻 e\u{301}") + #expect( + formatter.html() + == "

&<> &#10;
Café 👩🏽‍💻 é

") + } + + @Test func literalBreaksRetainInlineFormattingAndEscapedLinkDestinations() throws { + var content = AttributedString("first\nsecond") + content.presentationIntent = PresentationIntent(.paragraph, identity: 1) + content.inlinePresentationIntent = [.stronglyEmphasized, .emphasized] + content.link = URL(string: "https://example.test/?x=1&y=2") + let formatter = Textual.Formatter(content) + #expect(formatter.plainText() == "first\nsecond") + #expect( + formatter.html() + == "

first
second

" + ) + } + + @Test func codeBlocksKeepWhitespaceWithoutIntroducingHTMLBreakElements() throws { + let content = try AttributedString(markdown: "```swift\n first <&>\n second\n```") + let formatter = Textual.Formatter(content) + #expect(formatter.plainText().contains(" first <&>\n second")) + #expect(formatter.html().contains(" first <&>\n second")) + #expect(!formatter.html().contains("
")) + #expect(formatter.html().hasPrefix("
"))
+        }
+
+        #if canImport(AppKit)
+            @Test @MainActor func nativeHTMLImportKeepsVisibleIntraParagraphBreaks() throws {
+                let formatter = Textual.Formatter(try Self.observedSelection())
+                let imported = try NSAttributedString(
+                    data: Data(formatter.html().utf8),
+                    options: [.documentType: NSAttributedString.DocumentType.html], documentAttributes: nil)
+                // AppKit represents 
as LINE SEPARATOR. This tests rich-copy semantics; + // the separate explicit UTF-8 representation retains the canonical plain LF. + #expect(imported.string == "Soft first\u{2028}Soft second\nHard first\u{2028}Hard second\n") + } + #endif + + private static func observedSelection() throws -> NSAttributedString { + let output = NSMutableAttributedString(string: "") + let pieces: [(String, Int)] = [ + ("Soft first", 1), ("\n", 1), ("Soft second", 1), + ("Hard first", 2), ("\n", 2), ("Hard second", 2), + ] + for (index, piece) in pieces.enumerated() { + var content = AttributedString(piece.0) + content.presentationIntent = PresentationIntent(.paragraph, identity: piece.1) + let native = NSMutableAttributedString( + attributedString: try NSAttributedString(content, including: \.textual)) + native.addAttribute( + NSAttributedString.Key("MapleSyntheticCopyProbeRun"), value: index, + range: NSRange(location: 0, length: native.length)) + output.append(native) + } + return output + } + } +#endif diff --git a/apps/maple-ios/Tests/MapleAppUITests/TeamSeatMismatchPresentationTests.swift b/apps/maple-ios/Tests/MapleAppUITests/TeamSeatMismatchPresentationTests.swift new file mode 100644 index 000000000..af23226c5 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppUITests/TeamSeatMismatchPresentationTests.swift @@ -0,0 +1,107 @@ +import Foundation +import MapleAppCore +import Testing +@testable import MapleAppUI + +@Suite struct TeamSeatMismatchPresentationTests { + private let english = Locale(identifier: "en") + + @Test(arguments: [ + ("admin", false, true), ("member", true, true), ("admin", true, true), + ("member", false, false), ("ADMIN", false, false), ("owner", false, false), + ]) + func eitherExplicitAdminSignalSelectsAdminCopy(_ role: String, _ isAdmin: Bool, _ expectedAdmin: Bool) throws { + let status = try status(role: role, isAdmin: isAdmin) + let presentation = try #require(TeamSeatMismatchPresentation(status: status)) + #expect(presentation.audience == (expectedAdmin ? .admin : .member)) + let resolution = + expectedAdmin + ? "Remove members to match the paid seat count and resume team usage." + : "Contact your team admin to resolve the seat count." + #expect(presentation.message(locale: english) == "This team has 3 members but only 2 paid seats. \(resolution)") + } + + @Test func missingRoleUsesMemberCopyAndFallbackCounts() throws { + let status = try decode([ + "has_team_subscription": true, "team_created": true, "members_count": 5, "seats_purchased": 3, + ]) + let presentation = try #require(TeamSeatMismatchPresentation(status: status)) + #expect(presentation.audience == .member) + #expect(presentation.exactCounts == .init(members: 5, paidSeats: 3)) + #expect(presentation.summary(locale: english) == "5 members, 3 paid seats") + } + + @Test(arguments: [(1, 0), (2, 1), (3, 2)]) + func exactCountsUseIndependentMemberAndSeatPlurals(_ members: Int, _ paidSeats: Int) throws { + let memberLabel = members == 1 ? "member" : "members" + let seatLabel = paidSeats == 1 ? "seat" : "seats" + for role in ["admin", "member"] { + let presentation = try #require( + TeamSeatMismatchPresentation(status: status(role: role, members: members, paidSeats: paidSeats))) + #expect( + presentation.summary(locale: english) == "\(members) \(memberLabel), \(paidSeats) paid \(seatLabel)") + let resolution = + role == "admin" + ? "Remove members to match the paid seat count and resume team usage." + : "Contact your team admin to resolve the seat count." + #expect( + presentation.message(locale: english) + == "This team has \(members) \(memberLabel) but only \(paidSeats) paid \(seatLabel). \(resolution)") + } + } + + @Test(arguments: [(0, 0), (2, 2), (2, 3)]) + func serverFlagWithoutGreaterExactCountsUsesGenericCopy(_ members: Int, _ paidSeats: Int) throws { + let presentation = try #require( + TeamSeatMismatchPresentation( + status: status(role: "admin", members: members, paidSeats: paidSeats, exceeded: true))) + #expect(presentation.exactCounts == nil) + #expect(presentation.summary(locale: english) == "Seat count mismatch") + #expect( + presentation.message(locale: english) + == "This team has more members than paid seats. Remove members to match the paid seat count and resume team usage." + ) + } + + @Test func unknownCountsUseGenericMobileCopyForBothAudiences() throws { + for role in ["admin", "member"] { + let status = try decode([ + "has_team_subscription": true, "team_created": true, "role": role, "seat_limit_exceeded": true, + ]) + let presentation = try #require(TeamSeatMismatchPresentation(status: status)) + #expect(presentation.exactCounts == nil) + #expect(presentation.summary(locale: english) == "Seat count mismatch") + let resolution = + role == "admin" + ? "Remove members to match the paid seat count and resume team usage." + : "Contact your team admin to resolve the seat count." + #expect( + presentation.message(locale: english) == "This team has more members than paid seats. \(resolution)") + } + } + + @Test func absentTeamUncreatedTeamAndBalancedTeamDoNotShowNotice() throws { + #expect(TeamSeatMismatchPresentation(status: nil) == nil) + let uncreated = try decode([ + "has_team_subscription": true, "team_created": false, "seat_limit_exceeded": true, + "team_member_count": 3, "billed_seat_count": 2, + ]) + #expect(TeamSeatMismatchPresentation(status: uncreated) == nil) + #expect(TeamSeatMismatchPresentation(status: try status(role: "admin", members: 2, paidSeats: 2)) == nil) + } + + private func status( + role: String, isAdmin: Bool = false, members: Int = 3, paidSeats: Int = 2, exceeded: Bool = false + ) throws -> TeamStatus { + try decode([ + "has_team_subscription": true, "team_created": true, "role": role, "is_team_admin": isAdmin, + "team_member_count": members, "billed_seat_count": paidSeats, "seat_limit_exceeded": exceeded, + ]) + } + + private func decode(_ values: [String: Any]) throws -> TeamStatus { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return try decoder.decode(TeamStatus.self, from: JSONSerialization.data(withJSONObject: values)) + } +} diff --git a/apps/maple-ios/Tests/MapleAppUITests/TranscriptHistoryPaginationTests.swift b/apps/maple-ios/Tests/MapleAppUITests/TranscriptHistoryPaginationTests.swift new file mode 100644 index 000000000..1f71542c3 --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppUITests/TranscriptHistoryPaginationTests.swift @@ -0,0 +1,133 @@ +import Foundation +import Testing +@testable import MapleAppUI + +@Suite struct TranscriptHistoryPaginationTests { + @Test func initialLayoutRestorationAndForwardScrollNeverLoadHistory() { + var gate = TranscriptHistoryPagination() + let initialLayout = gate.shouldLoad(previousOffset: 200, offset: 20, canLoad: true, requestInFlight: false) + #expect(!initialLayout) + gate.gestureChanged(active: true) + let stationary = gate.shouldLoad(previousOffset: 20, offset: 20, canLoad: true, requestInFlight: false) + #expect(!stationary) + let forward = gate.shouldLoad(previousOffset: 20, offset: 30, canLoad: true, requestInFlight: false) + #expect(!forward) + let outsideThreshold = gate.shouldLoad(previousOffset: 200, offset: 101, canLoad: true, requestInFlight: false) + #expect(!outsideThreshold) + let subIntentMovement = gate.shouldLoad(previousOffset: 101, offset: 100, canLoad: true, requestInFlight: false) + #expect(!subIntentMovement) + let backwardAtThreshold = gate.shouldLoad( + previousOffset: 104, offset: 100, canLoad: true, requestInFlight: false) + #expect(backwardAtThreshold) + } + + @Test func eachPhysicalGestureLoadsAtMostOnePageIncludingMomentum() { + var gate = TranscriptHistoryPagination() + gate.gestureChanged(active: true) + let firstGesture = gate.shouldLoad(previousOffset: 110, offset: 90, canLoad: true, requestInFlight: false) + #expect(firstGesture) + gate.gestureChanged(active: true) + let sameGestureMomentum = gate.shouldLoad(previousOffset: 90, offset: 50, canLoad: true, requestInFlight: false) + #expect(!sameGestureMomentum) + gate.finishLoad(preserveQueued: true) + let noMomentumQueue = gate.startQueuedLoad(canLoad: true) + #expect(!noMomentumQueue) + gate.gestureChanged(active: false); gate.gestureChanged(active: true) + let nextGesture = gate.shouldLoad(previousOffset: 50, offset: -5, canLoad: true, requestInFlight: false) + #expect(nextGesture) + } + + @Test func unavailableInflightAndInvalidGeometryDoNotAdmitHistory() { + var gate = TranscriptHistoryPagination() + gate.gestureChanged(active: true) + let unavailable = gate.shouldLoad(previousOffset: 110, offset: 90, canLoad: false, requestInFlight: false) + #expect(!unavailable) + let inFlight = gate.shouldLoad(previousOffset: 110, offset: 90, canLoad: true, requestInFlight: true) + #expect(!inFlight) + gate.gestureChanged(active: false); gate.gestureChanged(active: true) + let invalidPrevious = gate.shouldLoad(previousOffset: .nan, offset: 90, canLoad: true, requestInFlight: false) + #expect(!invalidPrevious) + let invalidOffset = gate.shouldLoad( + previousOffset: 110, offset: .infinity, canLoad: true, requestInFlight: false) + #expect(!invalidOffset) + let valid = gate.shouldLoad(previousOffset: 110, offset: 90, canLoad: true, requestInFlight: false) + #expect(valid) + } + + @Test func aSecondBackwardGestureQueuesOnePageEvenWhenItEndsBeforeTheSlowPage() { + var gate = TranscriptHistoryPagination() + gate.gestureChanged(active: true) + let first = gate.shouldLoad(previousOffset: 110, offset: 90, canLoad: true, requestInFlight: false) + #expect(first && gate.isLoadInFlight) + gate.gestureChanged(active: false); gate.gestureChanged(active: true) + let queued = gate.shouldLoad(previousOffset: 90, offset: 70, canLoad: true, requestInFlight: true) + #expect(!queued) + // More physical gestures at the held boundary are still a bounded + // single queued request, rather than an unbounded pagination backlog. + gate.gestureChanged(active: false); gate.gestureChanged(active: true) + let repeatedQueue = gate.shouldLoad(previousOffset: 70, offset: 50, canLoad: true, requestInFlight: true) + #expect(!repeatedQueue) + gate.gestureChanged(active: false) + gate.finishLoad(preserveQueued: true) + let drain = gate.startQueuedLoad(canLoad: true) + #expect(drain && gate.isLoadInFlight) + gate.finishLoad(preserveQueued: true) + let noCascade = gate.startQueuedLoad(canLoad: true) + let noLayoutLoad = gate.shouldLoad(previousOffset: 150, offset: 50, canLoad: true, requestInFlight: false) + #expect(!noCascade && !noLayoutLoad) + } + + @Test func aNewProjectionDoesNotQueueAnUnownedRequestOrRetryAfterItSettles() { + var gate = TranscriptHistoryPagination() + gate.gestureChanged(active: true) + let external = gate.shouldLoad(previousOffset: 110, offset: 90, canLoad: true, requestInFlight: true) + #expect(!external && !gate.isLoadInFlight) + let sameGesture = gate.shouldLoad(previousOffset: 90, offset: 70, canLoad: true, requestInFlight: false) + let noQueue = gate.startQueuedLoad(canLoad: true) + #expect(!sameGesture && !noQueue) + } + + @Test func elasticRecoilAfterTheSecondBackwardGestureDoesNotCancelItsQueue() { + var gate = TranscriptHistoryPagination() + gate.gestureChanged(active: true) + _ = gate.shouldLoad(previousOffset: 110, offset: 90, canLoad: true, requestInFlight: false) + gate.gestureChanged(active: false); gate.gestureChanged(active: true) + _ = gate.shouldLoad(previousOffset: 0, offset: -10, canLoad: true, requestInFlight: true) + _ = gate.shouldLoad( + previousOffset: -10, offset: 0, canLoad: true, requestInFlight: true, + directUserInput: false) + gate.gestureChanged(active: false) + gate.finishLoad(preserveQueued: true) + let drain = gate.startQueuedLoad(canLoad: true) + #expect(drain) + } + + @Test(arguments: [true, false]) + func laterUserMotionAwayFromTheBoundaryCancelsTheQueuedPage(forward: Bool) { + var gate = TranscriptHistoryPagination() + gate.gestureChanged(active: true) + _ = gate.shouldLoad(previousOffset: 110, offset: 90, canLoad: true, requestInFlight: false) + gate.gestureChanged(active: false); gate.gestureChanged(active: true) + _ = gate.shouldLoad(previousOffset: 90, offset: 70, canLoad: true, requestInFlight: true) + _ = gate.shouldLoad( + previousOffset: forward ? 70 : 150, offset: forward ? 80 : 120, + canLoad: true, requestInFlight: true) + gate.gestureChanged(active: false) + gate.finishLoad(preserveQueued: true) + let drain = gate.startQueuedLoad(canLoad: true) + #expect(!drain) + } + + @Test(arguments: [true, false]) + func invalidatedProjectionOrUnsuccessfulPageDiscardsQueuedIntent(resetProjection: Bool) { + var gate = TranscriptHistoryPagination() + gate.gestureChanged(active: true) + _ = gate.shouldLoad(previousOffset: 110, offset: 90, canLoad: true, requestInFlight: false) + gate.gestureChanged(active: false); gate.gestureChanged(active: true) + _ = gate.shouldLoad(previousOffset: 90, offset: 70, canLoad: true, requestInFlight: true) + if resetProjection { gate.resetIntent() } + gate.finishLoad(preserveQueued: resetProjection) + let drain = gate.startQueuedLoad(canLoad: true) + #expect(!drain) + } +} diff --git a/apps/maple-ios/Tests/MapleAppUITests/TranscriptScrollPolicyTests.swift b/apps/maple-ios/Tests/MapleAppUITests/TranscriptScrollPolicyTests.swift new file mode 100644 index 000000000..4f4bc904b --- /dev/null +++ b/apps/maple-ios/Tests/MapleAppUITests/TranscriptScrollPolicyTests.swift @@ -0,0 +1,83 @@ +import Foundation +import Testing +@testable import MapleAppUI + +@Suite struct TranscriptScrollPolicyTests { + private func mutation(_ id: String?, assistant: Bool = true, streaming: Bool = false) + -> TranscriptScrollPolicy.Mutation + { + .init(lastItemID: id, lastItemIsAssistant: assistant, hasStreamingMessage: streaming) + } + + @Test func initialHistoryAndPrependingDoNotCountAsAppendedReplies() { + var policy = TranscriptScrollPolicy() + #expect(policy.receive(mutation(nil)).isEmpty) + #expect(policy.receive(mutation("loaded-last")).isEmpty) + #expect(policy.receive(mutation("loaded-last")).isEmpty) + } + + @Test func newAssistantAndToolItemsFollowOnlyWhileNearBottom() { + var policy = TranscriptScrollPolicy() + _ = policy.receive(mutation("user", assistant: false)) + #expect(policy.receive(mutation("assistant", streaming: true)) == [.appendedItem, .streamingStarted]) + policy.readerScrolled(distanceFromBottom: 99) + #expect(policy.receive(mutation("tool", streaming: true)) == [.appendedItem]) + policy.readerScrolled(distanceFromBottom: 100) + #expect(policy.receive(mutation("tool-result", streaming: true)).isEmpty) + policy.readerScrolled(distanceFromBottom: 0) + #expect(policy.receive(mutation("next-assistant", streaming: true)) == [.appendedItem]) + } + + @Test func tokenGrowthAndCompletionDoNotRepeatedlyScroll() { + var policy = TranscriptScrollPolicy() + _ = policy.receive(mutation("assistant")) + #expect(policy.receive(mutation("assistant", streaming: true)) == [.streamingStarted]) + for _ in 0..<40 { #expect(policy.receive(mutation("assistant", streaming: true)).isEmpty) } + #expect(policy.receive(mutation("assistant", streaming: false)).isEmpty) + } + + @Test func scrollingAwayAlsoSuppressesTheFirstStreamingNudge() { + var policy = TranscriptScrollPolicy() + _ = policy.receive(mutation("user", assistant: false)) + policy.readerScrolled(distanceFromBottom: 350) + #expect(policy.receive(mutation("assistant", streaming: true)).isEmpty) + #expect(policy.isUserScrolling) + policy.readerScrolled(distanceFromBottom: .nan) + #expect(policy.isUserScrolling) + } + + @Test func streamNudgeIsAtMost100PointsAndUsesTheNativeViewport() { + #expect(TranscriptScrollPolicy.maximumOffset(contentHeight: 900, viewportHeight: 500) == 400) + #expect(TranscriptScrollPolicy.streamStartOffset(current: 200, maximum: 400) == 300) + #expect(TranscriptScrollPolicy.streamStartOffset(current: 350, maximum: 400) == 400) + #expect(TranscriptScrollPolicy.streamStartOffset(current: -20, maximum: 400) == 100) + #expect(TranscriptScrollPolicy.maximumOffset(contentHeight: 300, viewportHeight: 500) == 0) + #expect(TranscriptScrollPolicy.maximumOffset(contentHeight: .nan, viewportHeight: 500) == nil) + #expect(TranscriptScrollPolicy.maximumOffset(contentHeight: 500, viewportHeight: 0) == nil) + } + + @Test func actualIPadBottomIsNotMisclassifiedByCountingInsetsTwice() throws { + // Observed at the physical bottom in the shipping iPad fixture: + // content=2223, container=928, top=182, bottom=100, rawOffset=1113. + // Its 1210-point visibleRect includes those 282 points of insets; + // its 928-point containerSize has already removed them. + let maximum = try #require(TranscriptScrollPolicy.maximumOffset(contentHeight: 2_223, viewportHeight: 928)) + #expect(maximum == 1_295) + var policy = TranscriptScrollPolicy() + policy.readerScrolled(distanceFromBottom: maximum - (1_113 + 182)) + #expect(!policy.isUserScrolling) + } + + @Test func appendedReplyWaitsForItsOwnLayoutBeforeChoosingTheBottomTarget() { + // A queued main-actor turn may still have the previous item's valid + // geometry. That is not evidence the newly appended reply fits there. + #expect( + TranscriptScrollPolicy.appendedItemOffset( + expectedItemID: "new-reply", renderedItemID: "old-reply", tailBottom: 900, + viewportHeight: 500) == nil) + #expect( + TranscriptScrollPolicy.appendedItemOffset( + expectedItemID: "new-reply", renderedItemID: "new-reply", tailBottom: 1_200, + viewportHeight: 500) == 720) + } +} diff --git a/apps/maple-ios/UITests/AccountLinkUITests.swift b/apps/maple-ios/UITests/AccountLinkUITests.swift new file mode 100644 index 000000000..04b20aa21 --- /dev/null +++ b/apps/maple-ios/UITests/AccountLinkUITests.swift @@ -0,0 +1,120 @@ +import XCTest + +/// Normal local account/root, actual onOpenURL delivery, shipping parser/forms. +/// The finite DEBUG bridge supplies only synthetic routes for unsigned loopback +/// builds. It does not request reset mail, reset a password or install credentials. +@MainActor final class AccountLinkUITests: XCTestCase { + private var app: XCUIApplication! + private var composer: XCUIElement { app.textViews["chat.composer"] } + + private func launch() throws { + try XCTSkipUnless(ProcessInfo.processInfo.environment["MAPLE_RUN_LOCAL_UI"] == "1") + continueAfterFailure = false + app = XCUIApplication(); app.launchArguments = ["--maple-account-link-fixture"]; app.launch() + XCTAssertTrue(composer.waitForExistence(timeout: 30)) + } + private func draft(_ value: String) throws { + try replaceSyntheticText(composer, with: value, in: app) + } + private func deliver(_ route: String, coldLaunch: Bool = false) throws { + let url = try XCTUnwrap(URL(string: "cloud.opensecret.maple.native.dev://ui-test-account-link/" + route)) + // Use system URL dispatch for warm delivery; application.open is + // launch-oriented and is reserved for the intentional cold-launch case. + if coldLaunch { app.open(url) } else { XCUIDevice.shared.system.open(url) } + } + private func delivered(_ count: Int) { + let marker = app.staticTexts["account-link.delivered"] + XCTAssertTrue(marker.waitForExistence(timeout: 20)) + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "label == %@", "Delivered links \(count)"), object: marker) + XCTAssertEqual(XCTWaiter.wait(for: [expectation], timeout: 15), .completed) + } + private func tap(_ identifier: String) { + let button = identifier == "chat.menu" ? app.mapleMenuButton : app.buttons[identifier].firstMatch + XCTAssertTrue(button.waitForExistence(timeout: 15)) + for _ in 0..<5 where !button.isHittable { app.swipeUp() } + XCTAssertTrue(button.isHittable); button.tap() + } + + func testUnownedAndMalformedNativeCallbacksCannotReplaceSessionOrDraft() throws { + try launch(); try draft("Root callback ownership stays local") + let values = [ + "cloud.opensecret.maple.native.dev://auth?handoff_grant=e30.e30.AA", + "cloud.opensecret.maple.native.dev://auth?handoff_grant=e30.e30.AA&handoff_grant=e30.e30.AQ", + "cloud.opensecret.maple.native.dev://wrong-owner?handoff_grant=e30.e30.AA", + ] + for (index, value) in values.enumerated() { + XCUIDevice.shared.system.open(try XCTUnwrap(URL(string: value))); delivered(index + 1) + XCTAssertTrue(composer.waitForExistence(timeout: 15)) + XCTAssertEqual(composer.value as? String, "Root callback ownership stays local") + XCTAssertFalse(app.buttons["auth.confirm-account"].exists) + XCTAssertFalse(app.textFields["auth.reset-email"].exists) + } + } + + func testColdResetLinkSurvivesRestoreWithoutAdoptingExternalSecretOrEmail() throws { + try launch(); try draft("Draft survives cold account link") + // Normal background handling durably saves the draft before termination. + XCUIDevice.shared.press(.home); app.terminate() + try deliver("reset", coldLaunch: true) + let email = app.textFields["auth.reset-email"] + XCTAssertTrue(email.waitForExistence(timeout: 30)) + XCTAssertFalse(email.isEnabled) + XCTAssertNotEqual(email.value as? String, "synthetic-other@example.test") + XCTAssertTrue( + app.staticTexts[ + "Start the reset in this app to use your code. A code alone cannot restore the private confirmation secret." + ].exists) + XCTAssertFalse(app.buttons["auth.confirm-reset"].exists) + tap("auth.request-reset") + XCTAssertTrue(app.staticTexts["Resetting your password deletes private data"].waitForExistence(timeout: 10)) + let warnings = app.popovers.containing(.sheet, identifier: "Resetting your password deletes private data") + XCTAssertEqual(warnings.count, 1) + let warning = warnings.element + XCTAssertTrue(warning.buttons["Continue with reset"].exists) + let dismissRegions = app.otherElements.matching( + NSPredicate(format: "identifier == %@ AND label == %@", "PopoverDismissRegion", "dismiss popup")) + XCTAssertEqual(dismissRegions.count, 1) + let dismissRegion = dismissRegions.element + XCTAssertTrue(dismissRegion.exists) + XCTAssertTrue(dismissRegion.isEnabled) + XCTAssertTrue(dismissRegion.isHittable) + dismissRegion.tap() + let warningDismissed = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "exists == false"), object: warning) + XCTAssertEqual(XCTWaiter.wait(for: [warningDismissed], timeout: 5), .completed) + tap("Done") + XCTAssertTrue(composer.waitForExistence(timeout: 20)) + XCTAssertEqual(composer.value as? String, "Draft survives cold account link") + delivered(1) + } + + func testVerificationLinkDismissesMenuBeforePresentingOwnedLocalForm() throws { + try launch(); try draft("Draft survives verification route") + tap("chat.menu") + XCTAssertTrue(app.buttons["library.close"].waitForExistence(timeout: 15)) + try deliver("verify") + let code = app.textFields["auth.verify-code"] + XCTAssertTrue(code.waitForExistence(timeout: 30)) + XCTAssertEqual(code.value as? String, "00000000-0000-4000-8000-000000000042") + XCTAssertFalse(app.buttons["library.close"].exists) + // A nonexistent synthetic verification code exercises the real local SDK + // error path without changing the account or sending verification mail. + XCTAssertTrue(app.staticTexts["auth.ceremony-error"].waitForExistence(timeout: 30)) + tap("Done") + XCTAssertTrue(composer.waitForExistence(timeout: 20)) + XCTAssertEqual(composer.value as? String, "Draft survives verification route") + delivered(1) + } + + func testForeignOriginAndDuplicateResetCodeCannotOpenAnAccountFlow() throws { + try launch(); try draft("Strict root route ownership") + for (index, route) in ["foreign-reset", "duplicate-reset"].enumerated() { + try deliver(route); delivered(index + 1) + XCTAssertTrue(composer.waitForExistence(timeout: 15)) + XCTAssertEqual(composer.value as? String, "Strict root route ownership") + XCTAssertFalse(app.textFields["auth.reset-email"].exists) + XCTAssertFalse(app.textFields["auth.verify-code"].exists) + } + } +} diff --git a/apps/maple-ios/UITests/AuthWorkflowUITests.swift b/apps/maple-ios/UITests/AuthWorkflowUITests.swift new file mode 100644 index 000000000..dc89bc21b --- /dev/null +++ b/apps/maple-ios/UITests/AuthWorkflowUITests.swift @@ -0,0 +1,273 @@ +import XCTest + +/// UI ownership/form evidence; encrypted transport and server contracts have a +/// separate local integration gate. This fixture never contacts a real provider. +@MainActor final class AuthWorkflowUITests: XCTestCase { + private var app: XCUIApplication! + + private func launchFixture(clockFailure: Bool = false, expiry: Bool = false) { + continueAfterFailure = false + app = XCUIApplication(); app.launchArguments = ["--maple-auth-workflow-fixture"] + if expiry { app.launchArguments.append("--maple-auth-expiry-fixture") } + if clockFailure { app.launchArguments.append("--maple-auth-clock-fixture") } + app.launch() + waitLabel("auth.fixture.phase", "signedOut") + } + private func waitLabel(_ identifier: String, _ value: String) { + let element = app.staticTexts[identifier] + XCTAssertTrue(element.waitForExistence(timeout: 15)) + let changed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "label == %@", value), object: element) + XCTAssertEqual(XCTWaiter.wait(for: [changed], timeout: 15), .completed) + } + private func tap(_ identifier: String) { + let button = app.buttons[identifier] + XCTAssertTrue(button.waitForExistence(timeout: 10)) + if !button.isHittable { app.swipeUp() } + button.tap() + } + private func text(_ identifier: String, _ value: String, secure: Bool = false) { + let field = secure ? app.secureTextFields[identifier] : app.textFields[identifier] + XCTAssertTrue(field.waitForExistence(timeout: 10)); field.tap(); field.typeText(value) + guard secure else { return } + // Inspect native secure entry before submitting; system password assistance + // can interrupt typing. Preserve the evidence and the bounded real retry + // so form validation is never bypassed or silently discounted. + let lengthPredicate = NSPredicate(format: "value MATCHES %@", "^.{\(value.utf16.count)}$") + let initial = XCTNSPredicateExpectation(predicate: lengthPredicate, object: field) + if XCTWaiter.wait(for: [initial], timeout: 2) != .completed { + let current = (field.value as? String) ?? "" + let length = current == field.placeholderValue ? 0 : min(current.utf16.count, 256) + let evidence = XCTAttachment( + string: "Native secure entry length \(length); expected \(value.utf16.count); bounded typing retry") + evidence.name = "Secure field entry synchronization"; evidence.lifetime = .keepAlways; add(evidence) + let hierarchy = XCTAttachment(string: app.debugDescription) + hierarchy.name = "Synthetic secure-entry app hierarchy"; hierarchy.lifetime = .keepAlways; add(hierarchy) + let suggestions = app.windows.containing(.button, identifier: "GenerateStrongPasswordButton") + if suggestions.count > 0 || app.staticTexts["Use Strong Password?"].exists { + XCTAssertEqual(suggestions.count, 1) + let suggestion = suggestions.element(boundBy: 0) + XCTAssertTrue(suggestion.staticTexts["Use Strong Password?"].exists) + XCTAssertEqual(suggestion.buttons["GenerateStrongPasswordButton"].label, "Fill Strong Password") + let close = suggestion.buttons.matching( + NSPredicate(format: "identifier == %@ AND label == %@", "xmark", "Close")) + XCTAssertEqual(close.count, 1) + XCTAssertTrue(close.element.isEnabled) + XCTAssertTrue(close.element.isHittable) + close.element.tap() + let dismissed = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + suggestions.count == 0 && !self.app.staticTexts["Use Strong Password?"].exists + }, object: app) + XCTAssertEqual(XCTWaiter.wait(for: [dismissed], timeout: 5), .completed) + } + let retryValue = (field.value as? String) ?? "" + let retryLength = retryValue == field.placeholderValue ? 0 : min(retryValue.utf16.count, 256) + if retryLength > 0 { + field.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: retryLength)) + } + field.typeText(value) + } + let final = XCTNSPredicateExpectation(predicate: lengthPredicate, object: field) + XCTAssertEqual(XCTWaiter.wait(for: [final], timeout: 3), .completed) + } + private func signIn(password: String = "fixture-password") { + text("auth.identifier", "fixture@example.test") + text("auth.password", password, secure: true) + tap("auth.submit"); waitLabel("auth.fixture.phase", "signedIn") + waitLabel("auth.fixture.account", "fixture@example.test") + } + private func closeForm() { tap("auth.fixture.close") } + + func testEmailSignupRequiresEightCharactersAndLegalLinksUseConfirmation() { + launchFixture() + tap("auth.terms") + XCTAssertTrue(app.alerts["Open external link?"].waitForExistence(timeout: 5)) + XCTAssertTrue( + app.alerts.staticTexts.containing( + NSPredicate( + format: "label CONTAINS %@", "https://trymaple.ai/terms") + ).firstMatch.exists) + app.alerts.buttons["Cancel"].tap() + let year = Calendar(identifier: .gregorian).component(.year, from: Date()) + XCTAssertTrue(app.staticTexts["© \(year) Maple Privacy Labs Inc."].exists) + tap("auth.privacy") + XCTAssertTrue(app.alerts["Open external link?"].waitForExistence(timeout: 5)) + XCTAssertTrue( + app.alerts.staticTexts.containing( + NSPredicate( + format: "label CONTAINS %@", "https://trymaple.ai/privacy") + ).firstMatch.exists) + app.alerts.buttons["Cancel"].tap() + app.swipeDown() + tap("Create an account") + XCTAssertFalse(app.textFields["Name (optional)"].exists) + text("auth.identifier", "fixture@example.test") + text("auth.password", "1234567", secure: true) + XCTAssertFalse(app.buttons["auth.submit"].isEnabled) + app.secureTextFields["auth.password"].typeText("8") + let valid = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "enabled == true"), object: app.buttons["auth.submit"]) + XCTAssertEqual(XCTWaiter.wait(for: [valid], timeout: 5), .completed) + } + + /// Terminal refresh rejection is injected; authority events, ownership + /// retirement, and the native shipping sign-in screen are actual code. + func testTerminalRefreshRejectionRetiresAuthorityAndShowsSignInWithoutAutomaticRetry() { + launchFixture(expiry: true); signIn() + waitLabel("auth.fixture.work", "Owned work retained") + tap("auth.fixture.expire") + waitLabel("auth.fixture.phase", "signedOut") + waitLabel("auth.fixture.authority", "Revocations 1; retirements 1; signouts 0") + waitLabel("auth.fixture.work", "Owned work cleared") + XCTAssertFalse(app.staticTexts["auth.fixture.account"].exists) + XCTAssertFalse(app.staticTexts["auth.fixture.revocation-error"].exists) + XCTAssertTrue(app.textFields["auth.identifier"].isHittable) + XCTAssertTrue(app.buttons["auth.submit"].exists) + let capture = XCTAttachment(screenshot: app.screenshot()) + capture.name = "Reference — injected terminal refresh rejection — actual authority observer and sign-in form" + capture.lifetime = .keepAlways; add(capture) + signIn() + waitLabel("auth.fixture.authority", "Revocations 1; retirements 1; signouts 0") + } + + func testTypedAttestationClockFailureShowsSafeGuidanceAndAllowsExplicitRetry() { + launchFixture(clockFailure: true) + text("auth.identifier", "fixture@example.test") + text("auth.password", "fixture-password", secure: true) + tap("auth.submit") + waitLabel( + "auth.error", + "This device’s date or time appears incorrect. Check Date & Time and the time zone in Settings, then try again." + ) + waitLabel("auth.fixture.phase", "signedOut") + XCTAssertFalse(app.staticTexts["auth.fixture.account"].exists) + let password = app.secureTextFields["auth.password"] + let cleared = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + let value = password.value as? String + return value == "" || value == password.placeholderValue + }, object: password) + XCTAssertEqual(XCTWaiter.wait(for: [cleared], timeout: 5), .completed) + let capture = XCTAttachment(screenshot: app.screenshot()) + capture.name = "Reference — injected typed attestation clock guidance — actual sign-in form" + capture.lifetime = .keepAlways; add(capture) + text("auth.password", "fixture-password", secure: true) + tap("auth.submit") + waitLabel("auth.fixture.account", "fixture@example.test") + waitLabel("auth.fixture.method", "email") + XCTAssertFalse(app.staticTexts["auth.error"].exists) + } + + func testEmailFormRejectsInvalidCredentialsThenResolvesOwnedAccount() { + launchFixture() + text("auth.identifier", "fixture@example.test") + text("auth.password", "incorrect", secure: true) + tap("auth.submit") + XCTAssertTrue(app.staticTexts["auth.error"].waitForExistence(timeout: 10)) + waitLabel("auth.fixture.phase", "signedOut") + text("auth.password", "fixture-password", secure: true) + tap("auth.submit"); waitLabel("auth.fixture.account", "fixture@example.test") + waitLabel("auth.fixture.method", "email") + } + + func testExistingGuestAndEmailSignupUseActualModeForms() { + launchFixture(); tap("Use an existing guest account") + text("auth.identifier", "existing-guest"); text("auth.password", "fixture-password", secure: true) + tap("auth.submit"); waitLabel("auth.fixture.method", "guest") + waitLabel("auth.fixture.account", "existing-guest") + tap("auth.fixture.sign-out"); waitLabel("auth.fixture.phase", "signedOut") + tap("Create an account") + text("auth.identifier", "new@example.test"); text("auth.password", "signup-password", secure: true) + tap("auth.submit"); waitLabel("auth.fixture.account", "new@example.test") + waitLabel("auth.fixture.method", "email") + } + + func testPasswordResetRequiresWarningAndOwnedSecretBeforeConfirmation() { + launchFixture(); tap("auth.forgot") + text("auth.reset-email", "fixture@example.test") + tap("auth.request-reset") + XCTAssertTrue(app.buttons["Continue with reset"].waitForExistence(timeout: 10)) + tap("Continue with reset") + text("auth.reset-code", "ABCD1234") + text("auth.reset-password", "reset-password", secure: true) + text("auth.reset-confirm-password", "reset-password", secure: true) + tap("auth.confirm-reset") + XCTAssertTrue(app.buttons["Return to sign-in"].waitForExistence(timeout: 10)) + closeForm() + waitLabel("auth.fixture.resets", "Reset requests 1; confirmations 1") + signIn(password: "reset-password") + } + + func testVerificationAndPasswordChangeKeepTheirAccountScope() { + launchFixture(); signIn() + tap("auth.fixture.verify"); tap("auth.verify-resend") + text("auth.verify-code", "11111111-2222-4333-8444-555555555555") + tap("auth.verify-submit") + XCTAssertTrue(app.staticTexts["auth.verified"].waitForExistence(timeout: 10)); closeForm() + tap("auth.fixture.change") + text("auth.current-password", "wrong-password", secure: true) + text("auth.new-password", "changed-password", secure: true) + text("auth.confirm-new-password", "changed-password", secure: true) + tap("auth.change-password") + XCTAssertTrue(app.staticTexts["auth.ceremony-error"].waitForExistence(timeout: 10)); closeForm() + tap("auth.fixture.change") + text("auth.current-password", "fixture-password", secure: true) + text("auth.new-password", "changed-password", secure: true) + text("auth.confirm-new-password", "changed-password", secure: true) + tap("auth.change-password") + XCTAssertTrue(app.staticTexts["auth.password-changed"].waitForExistence(timeout: 10)); closeForm() + tap("auth.fixture.sign-out"); waitLabel("auth.fixture.phase", "signedOut") + signIn(password: "changed-password") + } + + func testDeletionRequiresTypedIntentCodeAndQuiescenceBeforeCleanup() { + launchFixture(); signIn(); tap("auth.fixture.delete") + XCTAssertFalse(app.buttons["auth.request-delete"].isEnabled) + text("auth.delete-confirmation", "DELETE"); tap("auth.request-delete") + text("auth.delete-code", "11111111-2222-4333-8444-555555555555") + tap("auth.confirm-delete") + XCTAssertTrue(app.staticTexts["auth.account-deleted"].waitForExistence(timeout: 10)); closeForm() + waitLabel("auth.fixture.phase", "signedOut") + waitLabel("auth.fixture.deletions", "Deletion fences 1; cleanups 1") + } + + func testDuplicateBrowserDeliveriesRedeemOnceAndRequireExplicitAccountConfirmation() { + launchFixture(); tap("auth.google") + waitLabel("auth.fixture.phase", "authenticating") + waitLabel("auth.fixture.request", "Local request valid") + tap("auth.fixture.callback-twice") + waitLabel("auth.fixture.redemptions", "Redemptions 1") + waitLabel("auth.fixture.installs", "Installs 0") + tap("auth.fixture.release") + XCTAssertTrue(app.buttons["auth.confirm-account"].waitForExistence(timeout: 10)) + waitLabel("auth.fixture.phase", "authenticating") + waitLabel("auth.fixture.installs", "Installs 0") + tap("auth.confirm-account") + waitLabel("auth.fixture.account", "browser@example.test") + waitLabel("auth.fixture.installs", "Installs 1") + } + + func testInvalidBrowserCallbackAndLateCancelledAttemptCannotInstall() { + launchFixture(); tap("auth.google"); tap("auth.fixture.wrong-callback") + waitLabel("auth.fixture.phase", "signedOut") + XCTAssertTrue(app.staticTexts["auth.ceremony-error"].waitForExistence(timeout: 10)) + waitLabel("auth.fixture.redemptions", "Redemptions 0") + tap("auth.github"); waitLabel("auth.fixture.phase", "authenticating") + tap("auth.fixture.old-callback") + waitLabel("auth.fixture.redemptions", "Redemptions 0") + waitLabel("auth.fixture.phase", "authenticating") + tap("auth.fixture.provider-cancel"); waitLabel("auth.fixture.phase", "signedOut") + tap("auth.fixture.callback-twice") + waitLabel("auth.fixture.redemptions", "Redemptions 0") + waitLabel("auth.fixture.installs", "Installs 0") + } + + func testCancelDuringBrowserRedemptionPreservesSignedOutStateAndDropsLateResult() { + launchFixture(); tap("auth.google"); tap("auth.fixture.callback-twice") + waitLabel("auth.fixture.redemptions", "Redemptions 1") + tap("auth.cancel-provider"); waitLabel("auth.fixture.phase", "signedOut") + tap("auth.fixture.release"); tap("auth.fixture.callback-twice") + waitLabel("auth.fixture.installs", "Installs 0") + XCTAssertFalse(app.buttons["auth.confirm-account"].exists) + } +} diff --git a/apps/maple-ios/UITests/BrowserUITests.swift b/apps/maple-ios/UITests/BrowserUITests.swift new file mode 100644 index 000000000..0fa78fb60 --- /dev/null +++ b/apps/maple-ios/UITests/BrowserUITests.swift @@ -0,0 +1,90 @@ +import XCTest + +@MainActor final class BrowserUITests: XCTestCase { + private func fixture() throws -> XCUIApplication { + let address = try XCTUnwrap(ProcessInfo.processInfo.environment["MAPLE_LINK_FIXTURE_URL"]) + let app = XCUIApplication() + app.launchArguments = ["--maple-browser-fixture", "--maple-browser-url", address] + app.launch(); continueAfterFailure = false + XCTAssertTrue(app.textViews["browser.fixture.draft"].waitForExistence(timeout: 15)) + return app + } + private func tapPreparedLocalReference(in app: XCUIApplication) { + // Textual's native selection overlay also handles link taps. The + // underlying SwiftUI Link can exist without its own hittable point. + let owners = app.otherElements.matching(NSPredicate(format: "label == %@", "Message")) + let owner = owners.firstMatch + let links = owner.links.matching(identifier: "Local reference") + let fields = owner.textViews.matching(NSPredicate(format: "value == %@", "Local reference")) + let field = fields.firstMatch + let ready = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + guard owners.count == 1, links.count == 1, fields.count == 1, + field.exists, field.isHittable + else { return false } + let nativeFrame = field.frame, linkFrame = links.firstMatch.frame + guard + [ + nativeFrame.minX, nativeFrame.minY, nativeFrame.width, nativeFrame.height, + linkFrame.minX, linkFrame.minY, linkFrame.width, linkFrame.height, + ].allSatisfy({ $0.isFinite }), + nativeFrame.width > 0, nativeFrame.height > 0, linkFrame.width > 0, linkFrame.height > 0, + app.frame.contains(nativeFrame) + else { return false } + return abs(nativeFrame.minX - linkFrame.minX) <= 1 + && abs(nativeFrame.minY - linkFrame.minY) <= 1 + && abs(nativeFrame.width - linkFrame.width) <= 1 + && abs(nativeFrame.height - linkFrame.height) <= 1 + }, object: field) + XCTAssertEqual(XCTWaiter.wait(for: [ready], timeout: 10), .completed) + XCTAssertEqual(owners.count, 1); XCTAssertEqual(links.count, 1); XCTAssertEqual(fields.count, 1) + XCTAssertEqual(field.value as? String, "Local reference") + XCTAssertTrue(field.isHittable) + field.tap() + } + func testDeclineAndRejectUnsafeSchemePreserveDraft() throws { + let app = try fixture() + let link = app.links["Local reference"] + XCTAssertTrue(link.waitForExistence(timeout: 10)); tapPreparedLocalReference(in: app) + XCTAssertTrue(app.buttons["link.confirm"].waitForExistence(timeout: 5)) + app.alerts.buttons["Cancel"].tap() + XCTAssertEqual(app.textViews["browser.fixture.draft"].value as? String, "Keep this browser draft") + app.buttons["browser.fixture.unsupported"].tap() + XCTAssertTrue(app.alerts.firstMatch.waitForExistence(timeout: 5)) + XCTAssertFalse(app.buttons["link.confirm"].exists) + app.alerts.buttons["Cancel"].tap() + XCTAssertEqual(app.staticTexts["browser.fixture.visibility"].label, "Chat visible") + } + func testBrowserUsesCurrentPageForExternalEscapeAndReturnsToDraft() throws { + let app = try fixture() + let link = app.links["Local reference"] + XCTAssertTrue(link.waitForExistence(timeout: 10)); tapPreparedLocalReference(in: app) + let alerts = app.alerts.matching(NSPredicate(format: "label == %@", "Open external link?")) + let alert = alerts.element(boundBy: 0) + XCTAssertTrue(alert.waitForExistence(timeout: 5)); XCTAssertEqual(alerts.count, 1) + let confirmations = alert.buttons.matching( + NSPredicate(format: "identifier == %@ AND label == %@", "link.confirm", "Open link") + ) + .allElementsBoundByIndex.filter { $0.descendants(matching: .button).count == 0 } + XCTAssertEqual(confirmations.count, 1) + let confirmation = try XCTUnwrap(confirmations.first) + XCTAssertTrue(confirmation.exists); XCTAssertTrue(confirmation.isEnabled); + XCTAssertTrue(confirmation.isHittable) + confirmation.tap() + XCTAssertTrue(app.buttons["browser.close"].waitForExistence(timeout: 10)) + let next = app.webViews.links["Second local page"] + XCTAssertTrue(next.waitForExistence(timeout: 15)); next.tap() + XCTAssertTrue(app.webViews.staticTexts["Second local page reached"].waitForExistence(timeout: 15)) + let screenshot = XCTAttachment(screenshot: app.screenshot()) + screenshot.name = "Local external browser second page"; screenshot.lifetime = .keepAlways; add(screenshot) + app.buttons["browser.external"].tap() + let safari = XCUIApplication(bundleIdentifier: "com.apple.mobilesafari") + XCTAssertTrue(safari.wait(for: .runningForeground, timeout: 15)) + XCTAssertTrue(safari.webViews.staticTexts["Second local page reached"].waitForExistence(timeout: 15)) + app.activate() + XCTAssertTrue(app.buttons["browser.close"].waitForExistence(timeout: 10)); app.buttons["browser.close"].tap() + XCTAssertTrue(app.textViews["browser.fixture.draft"].waitForExistence(timeout: 10)) + XCTAssertEqual(app.textViews["browser.fixture.draft"].value as? String, "Keep this browser draft") + XCTAssertEqual(app.staticTexts["browser.fixture.visibility"].label, "Chat visible") + } +} diff --git a/apps/maple-ios/UITests/ChatOptionsUITests.swift b/apps/maple-ios/UITests/ChatOptionsUITests.swift new file mode 100644 index 000000000..921e5db4a --- /dev/null +++ b/apps/maple-ios/UITests/ChatOptionsUITests.swift @@ -0,0 +1,179 @@ +import XCTest + +/// Real sheet/navigation and account-owned LibraryModel over an isolated +/// in-memory service. These tests never construct a client or network request. +@MainActor final class ChatOptionsUITests: XCTestCase { + private var app: XCUIApplication! + + private func launch() { + continueAfterFailure = false + app = XCUIApplication() + app.launchArguments = ["--maple-library-controls-fixture", "--maple-chat-options-proof"] + app.launch() + waitLabel("library.fixture.ready", "ready") + } + + private func tap(_ identifier: String) { + let button = app.buttons[identifier].firstMatch + XCTAssertTrue(button.waitForExistence(timeout: 15)) + XCTAssertTrue(button.isEnabled, "Disabled control: " + identifier) + for _ in 0..<4 where !button.isHittable { app.swipeUp() } + XCTAssertTrue(button.isHittable); button.tap() + } + + private func waitLabel(_ identifier: String, _ value: String) { + let element = app.staticTexts[identifier] + XCTAssertTrue(element.waitForExistence(timeout: 15)) + let expected = XCTNSPredicateExpectation(predicate: NSPredicate(format: "label == %@", value), object: element) + XCTAssertEqual(XCTWaiter.wait(for: [expected], timeout: 15), .completed) + } + + private func optionsRoot() { + XCTAssertTrue(app.buttons["chat-options.project"].waitForExistence(timeout: 15)) + XCTAssertTrue(app.switches["model.web-search"].exists) + XCTAssertTrue(app.buttons["settings.model.auto:quick"].exists) + XCTAssertFalse(app.staticTexts["Default model"].exists) + XCTAssertFalse(app.buttons["Done"].exists) + } + + private func back() { + let button = app.navigationBars.buttons.firstMatch + XCTAssertTrue(button.waitForExistence(timeout: 10)); button.tap() + } + + private func editName(_ name: String, replacing previous: String? = nil) { + let field = app.textFields["project-editor.name"] + XCTAssertTrue(field.waitForExistence(timeout: 10)); field.tap() + if let previous { field.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: previous.count)) } + field.typeText(name); tap("project-editor.save") + } + + private func assertSheetFrame( + _ actual: CGRect, equals expected: CGRect, file: StaticString = #filePath, line: UInt = #line + ) { + XCTAssertGreaterThan(actual.width, 0, file: file, line: line) + XCTAssertGreaterThan(actual.height, 0, file: file, line: line) + XCTAssertEqual(actual.minX, expected.minX, accuracy: 1, file: file, line: line) + XCTAssertEqual(actual.minY, expected.minY, accuracy: 1, file: file, line: line) + XCTAssertEqual(actual.width, expected.width, accuracy: 1, file: file, line: line) + XCTAssertEqual(actual.maxY, expected.maxY, accuracy: 1, file: file, line: line) + } + + private func recordFrames(_ name: String, _ frames: String) { + let attachment = XCTAttachment(string: frames) + attachment.name = name; attachment.lifetime = .keepAlways; add(attachment) + } + + func testProjectSelectionAndWebPreferenceKeepOptionsOpenUntilModelChoice() { + launch(); tap("chat-options.fixture.open"); optionsRoot() + let models = app.collectionViews["settings.models"] + XCTAssertTrue(models.waitForExistence(timeout: 10)) + let rootScreenshot = XCTAttachment(screenshot: app.screenshot()) + rootScreenshot.name = "Options root for native sheet spacing review" + rootScreenshot.lifetime = .keepAlways; add(rootScreenshot) + let outerSheet = app.otherElements.matching(identifier: "chat.options-sheet").allElementsBoundByIndex.first { + let frame = $0.frame + return frame.width.isFinite && frame.height.isFinite && frame.width > 0 && frame.height > 0 + } + let boundaryKind = outerSheet == nil ? "content canvas (root list / destination list and bar)" : "outer sheet" + let initialSheetFrame = outerSheet?.frame ?? models.frame + let project = app.buttons["chat-options.project"] + recordFrames( + "Options root geometry boundary", + "boundary=\(boundaryKind)\ninitial=\(initialSheetFrame)\nmodels=\(models.frame)\nproject=\(project.frame)\nwindow=\(app.windows.firstMatch.frame)" + ) + XCTAssertGreaterThan(initialSheetFrame.width, 0) + XCTAssertGreaterThan(initialSheetFrame.height, 0) + XCTAssertGreaterThanOrEqual(project.frame.minY, initialSheetFrame.minY) + XCTAssertLessThanOrEqual( + project.frame.minY - initialSheetFrame.minY, 64, + "Project must stay near the measured \(boundaryKind) top") + XCTAssertLessThanOrEqual(project.frame.maxY, initialSheetFrame.maxY) + let toggle = app.switches["model.web-search"] + XCTAssertEqual(toggle.value as? String, "1"); tapNativeSwitch(toggle, in: app) + let changed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "value == %@", "0"), object: toggle) + XCTAssertEqual(XCTWaiter.wait(for: [changed], timeout: 10), .completed); optionsRoot() + tap("chat-options.project") + XCTAssertTrue(app.buttons["project-picker.project.none"].waitForExistence(timeout: 10)) + let projectList = app.collectionViews["project-picker.screen"] + let projectBar = app.navigationBars["Projects"] + XCTAssertTrue(projectList.waitForExistence(timeout: 10)) + XCTAssertTrue(projectBar.waitForExistence(timeout: 10)) + let projectSheetFrame: CGRect + if let outerSheet { + XCTAssertTrue(outerSheet.exists) + projectSheetFrame = outerSheet.frame + } else { + projectSheetFrame = projectList.frame.union(projectBar.frame) + } + recordFrames( + "Project destination geometry boundary", + "boundary=\(boundaryKind)\ninitial=\(initialSheetFrame)\ndestination=\(projectSheetFrame)\nlist=\(projectList.frame)\nbar=\(projectBar.frame)" + ) + assertSheetFrame(projectSheetFrame, equals: initialSheetFrame) + back(); optionsRoot() + if let outerSheet { XCTAssertTrue(outerSheet.exists) } + let returnedSheetFrame = outerSheet?.frame ?? models.frame + assertSheetFrame(returnedSheetFrame, equals: initialSheetFrame) + tap("chat-options.project"); tap("project-picker.project.fixture-project") + optionsRoot() + tap("chat-options.project"); tap("project-picker.project.none") + optionsRoot(); tap("settings.model.auto:powerful") + waitLabel("chat-options.fixture.project", "none") + waitLabel("chat-options.fixture.model", "auto:powerful") + waitLabel("chat-options.fixture.web", "web off") + XCTAssertFalse(app.buttons["chat-options.project"].exists) + tap("chat-options.fixture.open"); optionsRoot() + XCTAssertEqual(app.switches["model.web-search"].value as? String, "0") + } + + func testCreateRenameInstructionsAndConfirmedDeleteStayInsideProjectFlow() { + launch(); tap("chat-options.fixture.open"); tap("chat-options.project") + tap("project-picker.new-project"); editName("Options project") + optionsRoot() + tap("chat-options.project") + let existingProjectActions = app.buttons["project-picker.actions.fixture-project"] + XCTAssertTrue(existingProjectActions.waitForExistence(timeout: 10)) + XCTAssertGreaterThanOrEqual(existingProjectActions.frame.width, 44) + XCTAssertGreaterThanOrEqual(existingProjectActions.frame.height, 44) + tap("project-picker.actions.fixture-project") + XCTAssertTrue(app.navigationBars["Synthetic project"].waitForExistence(timeout: 10)) + back() + XCTAssertTrue( + app.buttons["project-picker.project.created-project-1"].isSelected, + "Opening project management must preserve the selected destination") + tap("project-picker.actions.created-project-1") + tap("project-editor.rename"); editName("Renamed options", replacing: "Options project") + XCTAssertTrue(app.navigationBars["Renamed options"].waitForExistence(timeout: 10)) + tap("project-editor.instructions") + let editor = app.textViews["library.instructions-text"] + XCTAssertTrue(editor.waitForExistence(timeout: 10)); editor.tap(); editor.typeText("Synthetic instructions") + tap("library.instructions-save") + XCTAssertTrue(app.buttons["project-editor.instructions"].waitForExistence(timeout: 10)) + tap("project-editor.instructions") + XCTAssertTrue(editor.waitForExistence(timeout: 10)) + XCTAssertEqual(editor.value as? String, "Synthetic instructions"); tap("library.instructions-cancel") + tap("project-editor.delete") + let confirmation = app.textFields["library.delete-confirmation"] + XCTAssertTrue(confirmation.waitForExistence(timeout: 10)) + XCTAssertFalse(app.buttons["library.delete-confirm"].isEnabled) + confirmation.tap(); confirmation.typeText("DELETE"); tap("library.delete-confirm") + XCTAssertTrue(app.buttons["project-picker.project.none"].waitForExistence(timeout: 15)) + XCTAssertFalse(app.buttons["project-picker.project.created-project-1"].exists) + XCTAssertTrue(app.buttons["project-picker.project.fixture-project"].exists) + back(); optionsRoot(); tap("settings.model.auto:quick") + waitLabel("chat-options.fixture.project", "none") + } + + func testMainMenuManagementUsesSameProjectScreensWithoutChangingChatSelection() { + launch(); tap("chat-options.fixture.manage") + tap("project-picker.new-project"); editName("Menu project") + XCTAssertTrue(app.buttons["project-picker.manage.created-project-1"].waitForExistence(timeout: 10)) + tap("project-picker.manage.created-project-1"); tap("project-editor.rename") + editName("Menu renamed", replacing: "Menu project") + XCTAssertTrue(app.navigationBars["Menu renamed"].waitForExistence(timeout: 10)) + back() + XCTAssertTrue(app.buttons["project-picker.manage.created-project-1"].waitForExistence(timeout: 10)) + XCTAssertFalse(app.buttons["project-picker.project.none"].exists) + } +} diff --git a/apps/maple-ios/UITests/ChatWorkflowUITests.swift b/apps/maple-ios/UITests/ChatWorkflowUITests.swift new file mode 100644 index 000000000..dbca536f5 --- /dev/null +++ b/apps/maple-ios/UITests/ChatWorkflowUITests.swift @@ -0,0 +1,425 @@ +import XCTest + +@MainActor final class ChatWorkflowUITests: XCTestCase { + private var app: XCUIApplication! + private var composer: XCUIElement { app.textViews["chat.fixture.composer"] } + private func launch( + reset: Bool = true, empty: Bool = false, images: Bool = false, dictation: Bool = false, context: Bool = false, + lifecycle: Bool = false + ) { + continueAfterFailure = false + app = XCUIApplication(); app.launchArguments = ["--maple-chat-workflow-fixture"] + if reset { app.launchArguments.append("--maple-chat-workflow-reset") } + if empty { app.launchArguments.append("--maple-chat-workflow-empty") } + if images { app.launchArguments.append("--maple-chat-workflow-images") } + if dictation { app.launchArguments.append("--maple-chat-workflow-dictation") } + if context { app.launchArguments.append("--maple-chat-workflow-context") } + if lifecycle { app.launchArguments.append("--maple-chat-workflow-lifecycle") } + app.launch(); XCTAssertTrue(composer.waitForExistence(timeout: 20)) + } + private func tap(_ identifier: String) { + let button = app.buttons[identifier].firstMatch + XCTAssertTrue(button.waitForExistence(timeout: 15)) + for _ in 0..<6 where !button.isHittable { + if button.frame.midY < app.frame.midY { app.swipeDown() } else { app.swipeUp() } + } + XCTAssertTrue(button.isHittable); button.tap() + } + private func waitLabel(_ identifier: String, _ value: String) { + let field = app.staticTexts[identifier] + XCTAssertTrue(field.waitForExistence(timeout: 15)) + let change = XCTNSPredicateExpectation(predicate: NSPredicate(format: "label == %@", value), object: field) + XCTAssertEqual(XCTWaiter.wait(for: [change], timeout: 15), .completed) + } + private func replaceComposer(_ text: String) throws { + try replaceSyntheticText(composer, with: text, in: app) + } + private func checkpoint() { tap("chat.fixture.checkpoint"); waitLabel("chat.fixture.saved", "saved") } + + func testProtectedQueueEditAndHeldComposerSurviveRealProcessRestart() throws { + launch(); tap("queue.open"); tap("Edit") + XCTAssertEqual(composer.value as? String, "Queued A") + try replaceComposer("Edited A awaiting review") + waitLabel("chat.fixture.held", "Held draft B"); checkpoint() + app.terminate(); launch(reset: false) + XCTAssertEqual(composer.value as? String, "Edited A awaiting review") + waitLabel("chat.fixture.held", "Held draft B") + waitLabel("chat.fixture.queued", "Queued A") + waitLabel("chat.fixture.queue-state", "paused"); waitLabel("chat.fixture.posts", "POSTs 0") + tap("queue.save") + XCTAssertEqual(composer.value as? String, "Held draft B") + waitLabel("chat.fixture.queued", "Edited A awaiting review"); checkpoint() + app.terminate(); launch(reset: false) + XCTAssertEqual(composer.value as? String, "Held draft B") + waitLabel("chat.fixture.queued", "Edited A awaiting review") + waitLabel("chat.fixture.posts", "POSTs 0") + tap("queue.open"); tap("Edit"); try replaceComposer("Discard this edit") + tap("chat.fixture.cancel-edit") + XCTAssertEqual(composer.value as? String, "Held draft B") + waitLabel("chat.fixture.queued", "Edited A awaiting review") + } + + func testInterruptedCanonicalRunRestoresAndReconcilesWithoutAnotherPost() throws { + launch(empty: true); tap("chat.send") + waitLabel("chat.fixture.posts", "POSTs 1"); waitLabel("chat.fixture.durability", "durable") + let canonicalID = app.staticTexts["chat.fixture.message-id"].label + XCTAssertNotNil(UUID(uuidString: canonicalID)); XCTAssertEqual(canonicalID, canonicalID.lowercased()) + tap("chat.fixture.interrupt"); waitLabel("chat.fixture.phase", "recovering") + try replaceComposer("New draft while result is uncertain") + checkpoint(); app.terminate(); launch(reset: false, empty: true) + waitLabel("chat.fixture.message-id", canonicalID); waitLabel("chat.fixture.posts", "POSTs 0") + XCTAssertEqual(composer.value as? String, "New draft while result is uncertain") + tap("chat.recover") + waitLabel("chat.fixture.issue", "uncertainResponse") + waitLabel("chat.fixture.last-lookup", canonicalID); waitLabel("chat.fixture.posts", "POSTs 0") + tap("chat.fixture.allow-recovery"); tap("chat.recover") + waitLabel("chat.fixture.phase", "idle") + waitLabel("chat.fixture.lookups", "Exact lookups 2"); waitLabel("chat.fixture.posts", "POSTs 0") + waitLabel("chat.fixture.message.recovered-assistant", "Recovered without replay") + XCTAssertEqual(composer.value as? String, "New draft while result is uncertain") + } + + func testForegroundReconcilesUnresolvedOwnerWithoutReplayOrQueueResume() throws { + try automaticRecovery(foreground: true) + } + func testReopeningReconcilesUnresolvedOwnerWithoutReplayOrQueueResume() throws { + try automaticRecovery(foreground: false) + } + private func automaticRecovery(foreground: Bool) throws { + launch(empty: true, lifecycle: true); tap("chat.send") + waitLabel("chat.fixture.posts", "POSTs 1") + let canonical = app.staticTexts["chat.fixture.message-id"].label + XCTAssertNotNil(UUID(uuidString: canonical)) + try replaceComposer("Paused queued A remains owned during recovery") + tap("chat.send"); tap("queue.open"); tap("queue.pause"); tap("Done") + try replaceComposer("Newer draft B must survive lifecycle recovery") + tap("chat.fixture.interrupt"); waitLabel("chat.fixture.phase", "recovering") + tap("chat.fixture.allow-recovery") + if foreground { + XCUIDevice.shared.press(.home); app.activate() + } else { + tap("chat.fixture.navigate"); tap("chat.fixture.navigate") + } + waitLabel("chat.fixture.phase", "idle") + waitLabel("chat.fixture.lookups", "Exact lookups 1") + waitLabel("chat.fixture.last-lookup", canonical) + waitLabel("chat.fixture.posts", "POSTs 1") + waitLabel("chat.fixture.queue-state", "paused") + waitLabel("chat.fixture.queued", "Paused queued A remains owned during recovery") + waitLabel("chat.fixture.message.recovered-assistant", "Recovered without replay") + XCTAssertEqual(composer.value as? String, "Newer draft B must survive lifecycle recovery") + XCTAssertFalse(app.buttons["chat.stop"].exists) + } + + func testStopBeforeResponseIDCancelsTheLateCreatedResponse() throws { + launch(empty: true); tap("chat.send"); waitLabel("chat.fixture.posts", "POSTs 1") + try replaceComposer("Keep typing after Stop") + tap("chat.stop"); waitLabel("chat.fixture.phase", "stopping") + tap("chat.fixture.created") + waitLabel("chat.fixture.cancels", "Cancels 1"); waitLabel("chat.fixture.phase", "idle") + waitLabel("chat.fixture.posts", "POSTs 1") + XCTAssertEqual(composer.value as? String, "Keep typing after Stop") + } + + func testOfflineBlocksDispatchAndReconnectLeavesQueuePausedUntilExplicitResume() throws { + launch(); tap("chat.fixture.connectivity") + XCTAssertTrue( + app.staticTexts["Offline — your draft stays here"].exists + || app.otherElements["chat.fixture.offline"].exists) + XCTAssertFalse(app.buttons["chat.send"].isEnabled) + try replaceComposer("Offline edits remain local") + tap("queue.open"); XCTAssertFalse(app.buttons["queue.resume"].isEnabled); tap("Done") + tap("chat.fixture.connectivity") + waitLabel("chat.fixture.queue-state", "paused"); waitLabel("chat.fixture.posts", "POSTs 0") + XCTAssertEqual(composer.value as? String, "Offline edits remain local") + tap("queue.open"); tap("queue.resume"); tap("Done") + waitLabel("chat.fixture.posts", "POSTs 1"); waitLabel("chat.fixture.durability", "durable") + XCTAssertEqual(composer.value as? String, "Offline edits remain local") + } + func testOfflineDuringActiveRunKeepsStopAndReconnectDoesNotResumeQueue() throws { + launch(empty: true); tap("chat.send") + waitLabel("chat.fixture.posts", "POSTs 1") + tap("chat.fixture.created"); waitLabel("chat.fixture.phase", "streaming") + try replaceComposer("Queued A survives an offline active run") + tap("chat.send") + waitLabel("chat.fixture.queued", "Queued A survives an offline active run") + try replaceComposer("Held draft B remains unsent") + tap("chat.fixture.connectivity") + waitLabel("chat.fixture.queue-state", "paused") + XCTAssertFalse(app.buttons["chat.send"].isEnabled) + XCTAssertTrue(app.buttons["chat.stop"].isEnabled) + tap("chat.stop") + waitLabel("chat.fixture.cancels", "Cancels 1") + waitLabel("chat.fixture.phase", "idle") + waitLabel("chat.fixture.posts", "POSTs 1") + waitLabel("chat.fixture.queued", "Queued A survives an offline active run") + XCTAssertEqual(composer.value as? String, "Held draft B remains unsent") + tap("chat.fixture.connectivity") + waitLabel("chat.fixture.queue-state", "paused") + waitLabel("chat.fixture.posts", "POSTs 1") + tap("chat.fixture.navigate"); tap("chat.fixture.navigate") + waitLabel("chat.fixture.queue-state", "paused") + waitLabel("chat.fixture.posts", "POSTs 1") + XCTAssertEqual(composer.value as? String, "Held draft B remains unsent") + checkpoint(); app.terminate(); launch(reset: false, empty: true) + waitLabel("chat.fixture.queue-state", "paused") + waitLabel("chat.fixture.posts", "POSTs 0") + waitLabel("chat.fixture.queued", "Queued A survives an offline active run") + XCTAssertEqual(composer.value as? String, "Held draft B remains unsent") + } + + func testFreshSendAfterEmptyQueueProcessRestartDrainsTheNextQueuedMessage() throws { + launch(empty: true) + try sendAndDrainQueue( + direct: "First direct message", queued: "First queued message", held: "Held composer before restart", + startingPosts: 0) + checkpoint(); app.terminate(); launch(reset: false, empty: true) + waitLabel("chat.fixture.queue-state", "paused") + waitLabel("chat.fixture.queue-count", "Queued turns 0") + waitLabel("chat.fixture.posts", "POSTs 0") + XCTAssertEqual(composer.value as? String, "Held composer before restart") + + try sendAndDrainQueue( + direct: "Fresh direct message after restart", queued: "Next queued message after restart", + held: "Newer composer after restart stays unsent", startingPosts: 0) + } + + func testFreshSendAfterEmptyQueueReconnectDrainsTheNextQueuedMessage() throws { + launch(empty: true) + try sendAndDrainQueue( + direct: "First direct message", queued: "First queued message", held: "Held composer before reconnect", + startingPosts: 0) + tap("chat.fixture.connectivity") + waitLabel("chat.fixture.queue-state", "paused") + waitLabel("chat.fixture.queue-count", "Queued turns 0") + XCTAssertFalse(app.buttons["chat.send"].isEnabled) + tap("chat.fixture.connectivity") + waitLabel("chat.fixture.queue-state", "paused") + waitLabel("chat.fixture.posts", "POSTs 2") + XCTAssertEqual(composer.value as? String, "Held composer before reconnect") + + try sendAndDrainQueue( + direct: "Fresh direct message after reconnect", queued: "Next queued message after reconnect", + held: "Newer composer after reconnect stays unsent", startingPosts: 2) + } + + func testFreshSendAfterStoppingWithEmptyQueueDrainsTheNextQueuedMessage() throws { + launch(empty: true) + try sendAndDrainQueue( + direct: "First direct message", queued: "First queued message", held: "Held composer before Stop", + startingPosts: 0) + try replaceComposer("Active reply to stop"); tap("chat.send") + waitLabel("chat.fixture.posts", "POSTs 3") + tap("chat.fixture.created"); waitLabel("chat.fixture.phase", "streaming") + try replaceComposer("Newer draft survives Stop") + waitLabel("chat.fixture.queue-count", "Queued turns 0") + tap("chat.stop") + waitLabel("chat.fixture.cancels", "Cancels 1") + waitLabel("chat.fixture.phase", "idle") + waitLabel("chat.fixture.queue-count", "Queued turns 0") + waitLabel("chat.fixture.posts", "POSTs 3") + XCTAssertEqual(composer.value as? String, "Newer draft survives Stop") + + try sendAndDrainQueue( + direct: "Newer draft survives Stop", queued: "Next queued message after Stop", + held: "Newer composer after Stop stays unsent", startingPosts: 3) + } + + private func sendAndDrainQueue(direct: String, queued: String, held: String, startingPosts: Int) throws { + try replaceComposer(direct); tap("chat.send") + waitLabel("chat.fixture.posts", "POSTs \(startingPosts + 1)") + waitLabel("chat.fixture.durability", "durable") + let directMessageID = app.staticTexts["chat.fixture.message-id"].label + XCTAssertNotNil(UUID(uuidString: directMessageID)) + waitLabel("chat.fixture.message." + directMessageID, direct) + tap("chat.fixture.created"); waitLabel("chat.fixture.phase", "streaming") + try replaceComposer(queued); tap("chat.send") + waitLabel("chat.fixture.queued", queued) + waitLabel("chat.fixture.queue-count", "Queued turns 1") + waitLabel("chat.fixture.queue-state", "running") + try replaceComposer(held) + tap("chat.fixture.complete") + + waitLabel("chat.fixture.posts", "POSTs \(startingPosts + 2)") + waitLabel("chat.fixture.queue-count", "Queued turns 0") + waitLabel("chat.fixture.durability", "durable") + let queuedMessageID = app.staticTexts["chat.fixture.message-id"].label + XCTAssertNotNil(UUID(uuidString: queuedMessageID)) + XCTAssertNotEqual(queuedMessageID, directMessageID) + waitLabel("chat.fixture.message." + queuedMessageID, queued) + XCTAssertEqual(composer.value as? String, held) + tap("chat.fixture.created"); waitLabel("chat.fixture.phase", "streaming") + tap("chat.fixture.complete"); waitLabel("chat.fixture.phase", "idle") + waitLabel("chat.fixture.queue-count", "Queued turns 0") + waitLabel("chat.fixture.posts", "POSTs \(startingPosts + 2)") + XCTAssertEqual(composer.value as? String, held) + XCTAssertFalse(app.buttons["chat.stop"].exists) + } + + func testRestoredQueueEditDeletePreservesHeldDraftAndImagesAcrossNavigation() throws { + launch(images: true) + waitLabel("chat.fixture.image-counts", "Composer images 2; held images 0; queued images 1") + tap("queue.open"); tap("Edit") + waitLabel("chat.fixture.image-counts", "Composer images 1; held images 2; queued images 1") + try replaceComposer("Edited A with retained image") + checkpoint(); app.terminate(); launch(reset: false, images: true) + XCTAssertEqual(composer.value as? String, "Edited A with retained image") + waitLabel("chat.fixture.image-counts", "Composer images 1; held images 2; queued images 1") + tap("chat.fixture.navigate") + XCTAssertTrue(app.staticTexts["chat.fixture.away"].waitForExistence(timeout: 5)) + tap("chat.fixture.navigate") + XCTAssertEqual(composer.value as? String, "Edited A with retained image") + tap("queue.open"); tap("Delete"); tap("Done") + XCTAssertEqual(composer.value as? String, "Held draft B") + waitLabel("chat.fixture.image-counts", "Composer images 2; held images 0; queued images 0") + waitLabel("chat.fixture.posts", "POSTs 0") + checkpoint(); app.terminate(); launch(reset: false, images: true) + XCTAssertEqual(composer.value as? String, "Held draft B") + waitLabel("chat.fixture.image-counts", "Composer images 2; held images 0; queued images 0") + waitLabel("chat.fixture.posts", "POSTs 0") + } + + func testActiveReplyCompletionCannotConsumeTheQueueItemBeingEdited() throws { + launch(empty: true); tap("chat.send"); waitLabel("chat.fixture.posts", "POSTs 1") + tap("chat.fixture.created") + try replaceComposer("Queued A during active reply"); tap("chat.send") + try replaceComposer("Held B while A is edited") + tap("queue.open"); tap("queue.pause"); tap("Edit") + try replaceComposer("Edited A during completion") + tap("chat.fixture.complete"); waitLabel("chat.fixture.phase", "idle") + XCTAssertEqual(composer.value as? String, "Edited A during completion") + waitLabel("chat.fixture.held", "Held B while A is edited") + waitLabel("chat.fixture.queued", "Queued A during active reply") + waitLabel("chat.fixture.posts", "POSTs 1") + tap("chat.fixture.cancel-edit") + XCTAssertEqual(composer.value as? String, "Held B while A is edited") + waitLabel("chat.fixture.queued", "Queued A during active reply") + waitLabel("chat.fixture.posts", "POSTs 1") + } + + func testRestoredQueueEditSavePreservesOriginalImageOwnersAndRestartPause() throws { + try finishRestoredImageEdit(save: true) + } + + func testRestoredQueueEditCancelPreservesOriginalImageOwnersAndRestartPause() throws { + try finishRestoredImageEdit(save: false) + } + + private func finishRestoredImageEdit(save: Bool) throws { + launch(images: true) + waitLabel("chat.fixture.image-counts", "Composer images 2; held images 0; queued images 1") + let heldImages = app.staticTexts["chat.fixture.composer-image-ids"].label + let originalImages = app.staticTexts["chat.fixture.queued-image-ids"].label + XCTAssertNotEqual(heldImages, "none"); XCTAssertNotEqual(originalImages, "none") + XCTAssertNotEqual(heldImages, originalImages) + tap("queue.open"); tap("Edit") + try replaceComposer("Unfinished A keeps its image") + waitLabel("chat.fixture.composer-image-ids", originalImages) + waitLabel("chat.fixture.held-image-ids", heldImages) + waitLabel("chat.fixture.queued", "Queued A") + checkpoint(); app.terminate(); launch(reset: false, images: true) + XCTAssertEqual(composer.value as? String, "Unfinished A keeps its image") + waitLabel("chat.fixture.composer-image-ids", originalImages) + waitLabel("chat.fixture.held-image-ids", heldImages) + waitLabel("chat.fixture.queued-image-ids", originalImages) + tap("chat.fixture.navigate"); tap("chat.fixture.navigate") + XCTAssertEqual(composer.value as? String, "Unfinished A keeps its image") + tap("queue.open") + XCTAssertFalse(app.buttons["queue.resume"].isEnabled, "Resume cannot bypass a restored unfinished edit") + tap("Done") + tap(save ? "queue.save" : "chat.fixture.cancel-edit") + let restoredComposer = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "value == %@", "Held draft B"), object: composer) + XCTAssertEqual(XCTWaiter.wait(for: [restoredComposer], timeout: 15), .completed) + XCTAssertEqual(composer.value as? String, "Held draft B") + waitLabel("chat.fixture.composer-image-ids", heldImages) + waitLabel("chat.fixture.held-image-ids", "none") + waitLabel("chat.fixture.queued-image-ids", originalImages) + let expectedQueued = save ? "Unfinished A keeps its image" : "Queued A" + waitLabel("chat.fixture.queued", expectedQueued) + waitLabel("chat.fixture.queue-state", "paused"); waitLabel("chat.fixture.posts", "POSTs 0") + checkpoint(); app.terminate(); launch(reset: false, images: true) + XCTAssertEqual(composer.value as? String, "Held draft B") + waitLabel("chat.fixture.composer-image-ids", heldImages) + waitLabel("chat.fixture.queued-image-ids", originalImages) + waitLabel("chat.fixture.queued", expectedQueued) + waitLabel("chat.fixture.queue-state", "paused"); waitLabel("chat.fixture.posts", "POSTs 0") + tap("queue.open"); XCTAssertTrue(app.buttons["queue.resume"].isEnabled); tap("Done") + } + + func testPendingDictationDoesNotRestartButUnfinishedImageEditAndHeldDraftDo() throws { + launch(images: true, dictation: true) + waitLabel("chat.fixture.image-counts", "Composer images 2; held images 0; queued images 1") + let heldImages = app.staticTexts["chat.fixture.composer-image-ids"].label + let originalImages = app.staticTexts["chat.fixture.queued-image-ids"].label + tap("queue.open"); tap("Edit"); try replaceComposer("Unfinished editor before capture") + tap("dictation.start"); waitLabel("chat.fixture.dictation-phase", "recording") + XCTAssertFalse(app.buttons["queue.save"].isEnabled) + tap("dictation.stop"); waitLabel("chat.fixture.dictation-phase", "transcribing") + checkpoint(); app.terminate(); launch(reset: false, images: true, dictation: true) + XCTAssertEqual(composer.value as? String, "Unfinished editor before capture") + waitLabel("chat.fixture.dictation-phase", "idle") + XCTAssertFalse(app.buttons["dictation.stop"].exists) + XCTAssertFalse(app.buttons["dictation.discard"].exists) + XCTAssertTrue(app.buttons["queue.save"].isEnabled) + waitLabel("chat.fixture.held", "Held draft B") + waitLabel("chat.fixture.queued", "Queued A") + waitLabel("chat.fixture.composer-image-ids", originalImages) + waitLabel("chat.fixture.held-image-ids", heldImages) + waitLabel("chat.fixture.queued-image-ids", originalImages) + waitLabel("chat.fixture.queue-state", "paused"); waitLabel("chat.fixture.posts", "POSTs 0") + tap("chat.fixture.cancel-edit") + XCTAssertEqual(composer.value as? String, "Held draft B") + waitLabel("chat.fixture.composer-image-ids", heldImages) + waitLabel("chat.fixture.queued", "Queued A") + waitLabel("chat.fixture.posts", "POSTs 0") + } + + func testContextLimitUsesShippingRecoveryControlsWithoutOverwritingNewDraftOrReplaying() throws { + launch(empty: true, context: true) + try replaceComposer("Rejected original message") + tap("chat.fixture.hold-context"); tap("chat.send") + waitLabel("chat.fixture.posts", "POSTs 1"); waitLabel("chat.fixture.context-gate", "held") + try replaceComposer("New composer while rejection is pending") + tap("chat.fixture.reject-context"); waitLabel("chat.fixture.phase", "idle") + waitLabel("chat.error", "Your message exceeds the context limit for this model.") + XCTAssertEqual(composer.value as? String, "New composer while rejection is pending") + waitLabel("chat.fixture.unsent", "Rejected original message") + tap("Restore unsent message") + XCTAssertEqual(composer.value as? String, "Rejected original message") + waitLabel("chat.fixture.unsent", "New composer while rejection is pending") + waitLabel("chat.fixture.posts", "POSTs 1") + checkpoint(); app.terminate(); launch(reset: false, empty: true, context: true) + XCTAssertEqual(composer.value as? String, "Rejected original message") + waitLabel("chat.fixture.unsent", "New composer while rejection is pending") + waitLabel("chat.fixture.posts", "POSTs 0") + tap("Restore unsent message") + XCTAssertEqual(composer.value as? String, "New composer while rejection is pending") + waitLabel("chat.fixture.unsent", "Rejected original message") + waitLabel("chat.fixture.posts", "POSTs 0") + } + + func testContextRejectedQueueHeadReturnsPausedWithItsImageAndHeldComposer() throws { + launch(images: true, context: true) + waitLabel("chat.fixture.image-counts", "Composer images 2; held images 0; queued images 1") + let heldImages = app.staticTexts["chat.fixture.composer-image-ids"].label + let queuedImages = app.staticTexts["chat.fixture.queued-image-ids"].label + tap("chat.fixture.hold-context"); tap("queue.open"); tap("queue.resume"); tap("Done") + waitLabel("chat.fixture.posts", "POSTs 1"); waitLabel("chat.fixture.context-gate", "held") + try replaceComposer("Held composer survives queued rejection") + tap("chat.fixture.reject-context"); waitLabel("chat.fixture.phase", "idle") + waitLabel("chat.error", "Your message exceeds the context limit for this model.") + waitLabel("chat.fixture.queue-state", "paused"); waitLabel("chat.fixture.queued", "Queued A") + waitLabel("chat.fixture.composer-image-ids", heldImages) + waitLabel("chat.fixture.queued-image-ids", queuedImages) + XCTAssertEqual(composer.value as? String, "Held composer survives queued rejection") + waitLabel("chat.fixture.posts", "POSTs 1") + checkpoint(); app.terminate(); launch(reset: false, images: true, context: true) + XCTAssertEqual(composer.value as? String, "Held composer survives queued rejection") + waitLabel("chat.fixture.queue-state", "paused"); waitLabel("chat.fixture.queued", "Queued A") + waitLabel("chat.fixture.composer-image-ids", heldImages) + waitLabel("chat.fixture.queued-image-ids", queuedImages) + waitLabel("chat.fixture.posts", "POSTs 0") + } + +} diff --git a/apps/maple-ios/UITests/DictationUITests.swift b/apps/maple-ios/UITests/DictationUITests.swift new file mode 100644 index 000000000..81ea346e3 --- /dev/null +++ b/apps/maple-ios/UITests/DictationUITests.swift @@ -0,0 +1,194 @@ +import XCTest + +@MainActor final class DictationUITests: XCTestCase { + private var app: XCUIApplication! + private var composer: XCUIElement { app.textViews["dictation.fixture.composer"] } + private func launchFixture() { + continueAfterFailure = false + app = XCUIApplication(); app.launchArguments = ["--maple-dictation-fixture"]; app.launch() + XCTAssertTrue(composer.waitForExistence(timeout: 15)) + } + private func tap(_ identifier: String) { + let control = app.buttons[identifier].firstMatch + XCTAssertTrue(control.waitForExistence(timeout: 10)) + for _ in 0..<6 where !control.isHittable { + if control.frame.midY < app.frame.midY { app.swipeDown() } else { app.swipeUp() } + } + XCTAssertTrue(control.isHittable); control.tap() + } + private func waitForPhase(_ phase: String) { + let changed = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "label == %@", phase), object: app.staticTexts["dictation.fixture.phase"]) + XCTAssertEqual(XCTWaiter.wait(for: [changed], timeout: 15), .completed) + } + private func startAndStop() { + tap("dictation.start"); waitForPhase("recording") + XCTAssertFalse(app.buttons["dictation.fixture.send"].isEnabled) + tap("dictation.stop"); waitForPhase("transcribing") + } + + func testInlineStopProgressAndInsertionNeverSendTheDraft() { + launchFixture(); composer.tap() + tap("dictation.fixture.cursor-end") + let caret = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "label == %@", "Caret 15"), + object: app.staticTexts["dictation.fixture.caret"]) + XCTAssertEqual(XCTWaiter.wait(for: [caret], timeout: 10), .completed) + tap("dictation.start"); waitForPhase("recording") + composer.typeText(" typed") + tap("dictation.stop"); waitForPhase("transcribing") + tap("dictation.fixture.complete"); waitForPhase("idle") + XCTAssertEqual(composer.value as? String, "Keep this draft typed spoken text") + XCTAssertEqual(app.staticTexts["dictation.fixture.sends"].label, "Sends 0") + XCTAssertTrue(app.buttons["dictation.fixture.send"].isEnabled) + } + + func testFailedTranscriptionOffersRetryAndDiscardPreservesText() { + launchFixture(); startAndStop() + tap("dictation.fixture.fail"); waitForPhase("failed") + XCTAssertTrue(app.buttons["dictation.retry"].exists) + let explanation = app.staticTexts["dictation.failed"] + XCTAssertTrue(explanation.exists) + let expired = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "label == %@", "none"), + object: app.staticTexts["dictation.fixture.feedback"]) + XCTAssertEqual(XCTWaiter.wait(for: [expired], timeout: 10), .completed) + XCTAssertEqual(app.staticTexts["dictation.fixture.phase"].label, "failed") + XCTAssertTrue( + explanation.exists, "The retained failed clip keeps its explanation after transient feedback expires") + XCTAssertEqual(explanation.label, "Failed to transcribe audio. Please try again.") + XCTAssertTrue(app.buttons["dictation.retry"].exists) + XCTAssertEqual(composer.value as? String, "Keep this draft") + tap("dictation.retry"); waitForPhase("transcribing") + tap("dictation.discard"); waitForPhase("idle") + tap("dictation.fixture.complete") + XCTAssertEqual(composer.value as? String, "Keep this draft") + XCTAssertTrue(app.buttons["dictation.fixture.send"].isEnabled) + } + + func testDeniedPermissionHasVisibleRecoveryWithoutChangingDraft() { + launchFixture(); tap("dictation.fixture.deny") + tap("dictation.start") + XCTAssertTrue( + app.otherElements["dictation.error"].waitForExistence(timeout: 10) + || app.buttons["Open Settings"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.buttons["Open Settings"].exists) + XCTAssertEqual(composer.value as? String, "Keep this draft") + XCTAssertTrue(app.buttons["dictation.fixture.send"].isEnabled) + let screenshot = XCTAttachment(screenshot: app.screenshot()) + screenshot.name = "Dictation permission recovery"; screenshot.lifetime = .keepAlways; add(screenshot) + } + + func testMicrophoneRespondsAcrossItsFullIconTargetAndDeniedRecoveryStaysVisible() { + launchFixture() + let start = app.buttons["dictation.start"] + XCTAssertTrue(start.waitForExistence(timeout: 10)) + for _ in 0..<6 where !start.isHittable { app.swipeUp() } + XCTAssertTrue(start.isHittable) + XCTAssertGreaterThanOrEqual(start.frame.width, 44) + XCTAssertGreaterThanOrEqual(start.frame.height, 44) + // These points lie outside the small mic glyph, inside its full button. + start.coordinate(withNormalizedOffset: CGVector(dx: 0.08, dy: 0.5)).tap() + waitForPhase("recording") + let stop = app.buttons["dictation.stop"] + XCTAssertTrue(stop.isHittable) + stop.coordinate(withNormalizedOffset: CGVector(dx: 0.92, dy: 0.5)).tap() + waitForPhase("transcribing") + tap("dictation.discard"); waitForPhase("idle") + tap("dictation.fixture.deny") + let deniedStart = app.buttons["dictation.start"] + XCTAssertTrue(deniedStart.isHittable) + deniedStart.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.08)).tap() + XCTAssertTrue(app.buttons["Open Settings"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.buttons["Dismiss"].isHittable) + XCTAssertEqual(composer.value as? String, "Keep this draft") + XCTAssertTrue(app.buttons["dictation.fixture.send"].isEnabled) + } + + func testMarkedHeldDraftReceivesResultAfterQueueEditorSwitch() { + launchFixture(); composer.tap() + tap("dictation.start"); waitForPhase("recording") + tap("dictation.fixture.mark") + tap("dictation.fixture.edit"); waitForPhase("transcribing") + XCTAssertEqual(composer.value as? String, "Queued A") + tap("dictation.fixture.complete"); waitForPhase("idle") + XCTAssertEqual(composer.value as? String, "Queued A") + XCTAssertTrue(app.staticTexts["dictation.fixture.held"].label.contains("spoken text")) + tap("dictation.fixture.cancel-edit") + XCTAssertTrue((composer.value as? String)?.contains("spoken text") == true) + XCTAssertEqual(app.staticTexts["dictation.fixture.sends"].label, "Sends 0") + } + func testCancellingPendingEditorARejectsLateResultAndKeepsHeldB() { + retirePendingEditor(delete: false) + } + + func testDeletingPendingEditorARejectsLateResultAndKeepsHeldB() { + retirePendingEditor(delete: true) + } + + func testCompletedEditorARequiresExplicitSaveWhileHeldBStillTranscribes() { + beginTwoOwnerTranscriptions() + tap("dictation.fixture.complete-latest") + waitForRequests("Requests 2; returned 1") + let inserted = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + (self.composer.value as? String)?.contains("late editor text") == true + && self.app.buttons["queue.save"].isEnabled + }, object: app) + XCTAssertEqual(XCTWaiter.wait(for: [inserted], timeout: 15), .completed) + let edited = composer.value as? String + XCTAssertEqual(app.staticTexts["dictation.fixture.queued"].label, "Queued A") + XCTAssertEqual(app.staticTexts["dictation.fixture.editor-state"].label, "editing") + tap("queue.save") + XCTAssertEqual(composer.value as? String, "Keep this draft") + XCTAssertEqual(app.staticTexts["dictation.fixture.queued"].label, edited) + XCTAssertFalse(app.buttons["dictation.fixture.send"].isEnabled, "Held B still owns its pending transcript") + tap("dictation.fixture.complete"); waitForPhase("idle") + XCTAssertTrue((composer.value as? String)?.contains("spoken text") == true) + XCTAssertFalse((composer.value as? String)?.contains("late editor text") == true) + XCTAssertEqual(app.staticTexts["dictation.fixture.queued"].label, edited) + XCTAssertEqual(app.staticTexts["dictation.fixture.sends"].label, "Sends 0") + } + + private func beginTwoOwnerTranscriptions() { + launchFixture() + tap("dictation.start"); waitForPhase("recording") + tap("dictation.fixture.edit"); waitForPhase("transcribing") + waitForRequests("Requests 1; returned 0") + XCTAssertEqual(composer.value as? String, "Queued A") + XCTAssertTrue(app.buttons["queue.save"].isEnabled, "Pending held B must not block unrelated editor A") + startAndStop(); waitForRequests("Requests 2; returned 0") + XCTAssertFalse(app.buttons["queue.save"].isEnabled, "Pending A must block committing A") + } + + private func retirePendingEditor(delete: Bool) { + beginTwoOwnerTranscriptions() + if delete { + tap("dictation.fixture.queue") + let remove = app.buttons["Delete"].firstMatch + XCTAssertTrue(remove.waitForExistence(timeout: 10)); remove.tap(); tap("Done") + } else { + tap("dictation.fixture.cancel-edit") + } + XCTAssertEqual(composer.value as? String, "Keep this draft") + XCTAssertFalse(app.buttons["dictation.fixture.send"].isEnabled) + tap("dictation.fixture.complete-latest") + waitForRequests("Requests 2; returned 1") + XCTAssertEqual(composer.value as? String, "Keep this draft") + XCTAssertEqual(app.staticTexts["dictation.fixture.queued"].label, delete ? "none" : "Queued A") + tap("dictation.fixture.complete"); waitForPhase("idle") + XCTAssertTrue((composer.value as? String)?.contains("spoken text") == true) + XCTAssertFalse((composer.value as? String)?.contains("late editor text") == true) + XCTAssertEqual(app.staticTexts["dictation.fixture.queued"].label, delete ? "none" : "Queued A") + XCTAssertEqual(app.staticTexts["dictation.fixture.sends"].label, "Sends 0") + XCTAssertTrue(app.buttons["dictation.fixture.send"].isEnabled) + } + + private func waitForRequests(_ expected: String) { + let changed = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "label == %@", expected), + object: app.staticTexts["dictation.fixture.requests"]) + XCTAssertEqual(XCTWaiter.wait(for: [changed], timeout: 15), .completed) + } + +} diff --git a/apps/maple-ios/UITests/EntitlementUITests.swift b/apps/maple-ios/UITests/EntitlementUITests.swift new file mode 100644 index 000000000..069261f56 --- /dev/null +++ b/apps/maple-ios/UITests/EntitlementUITests.swift @@ -0,0 +1,315 @@ +import XCTest + +/// Shipping native account/model/settings views over synthetic server responses. +/// Real authenticated billing/catalog requests have a separate local API gate. +@MainActor final class EntitlementUITests: XCTestCase { + private var app: XCUIApplication! + private var referenceScenario = "unknown" + private func referenceScreenshot(_ surface: String) { + let capture = XCTAttachment(screenshot: app.screenshot()) + capture.name = "Reference — injected entitlement — " + referenceScenario + " — " + surface + capture.lifetime = .keepAlways; add(capture) + } + private var editor: XCUIElement { app.textViews["entitlement.composer"] } + private func launch(guest: Bool = false) { + continueAfterFailure = false + referenceScenario = guest ? "guest-unknown" : "unknown" + app = XCUIApplication(); app.launchArguments = ["--maple-entitlement-fixture"] + if guest { app.launchArguments.append("--maple-entitlement-guest") } + app.launch() + XCTAssertTrue(editor.waitForExistence(timeout: 15)) + waitLabel("entitlement.billing", "unknown") + } + private func waitLabel(_ id: String, _ value: String) { + let match = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "label == %@", value), object: app.staticTexts[id]) + XCTAssertEqual(XCTWaiter.wait(for: [match], timeout: 10), .completed) + } + private func tap(_ id: String) { + let button = app.buttons[id] + XCTAssertTrue(button.waitForExistence(timeout: 5)) + if !button.isHittable { app.swipeUp() } + button.tap() + } + private func scenario(_ id: String, billing: String) { + if !app.buttons["entitlement.scenarios"].isHittable { app.swipeDown() } + tap("entitlement.scenarios"); tap("entitlement.scenario." + id) + waitLabel("entitlement.billing", billing) + referenceScenario = id + XCTAssertEqual(editor.value as? String, "Keep this unsent draft") + } + private func notice(_ feature: String, containing text: String) { + tap("entitlement.media." + feature) + let message = app.staticTexts["media.access.message"] + XCTAssertTrue(message.waitForExistence(timeout: 5)) + XCTAssertTrue(message.label.contains(text)) + XCTAssertFalse(app.buttons["Upgrade"].exists) + XCTAssertFalse(app.buttons["Buy"].exists) + referenceScreenshot(feature + " access explanation") + tap("media.access.close") + } + + private func waitSendEnabled(_ expected: Bool) { + let changed = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "enabled == %@", NSNumber(value: expected)), + object: app.buttons["entitlement.send"]) + XCTAssertEqual(XCTWaiter.wait(for: [changed], timeout: 10), .completed) + } + + private func tapPickerChoice(_ label: String) { + let choices = app.descendants(matching: .any).matching( + NSPredicate( + format: "label == %@ AND (elementType == %d OR elementType == %d)", label, + XCUIElement.ElementType.button.rawValue, XCUIElement.ElementType.menuItem.rawValue)) + let ready = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + choices.allElementsBoundByIndex.filter { $0.isEnabled && $0.isHittable }.count == 1 + }, object: nil) + XCTAssertEqual(XCTWaiter.wait(for: [ready], timeout: 5), .completed) + let visible = choices.allElementsBoundByIndex.filter { $0.isEnabled && $0.isHittable } + XCTAssertEqual(visible.count, 1) + visible.first?.tap() + } + + private func tapSettingsRow(_ id: String) { + let forms = app.collectionViews.matching(identifier: "settings.screen") + let form = forms.firstMatch + XCTAssertTrue(form.waitForExistence(timeout: 5)) + XCTAssertEqual(forms.count, 1) + let controls = form.buttons.matching(identifier: id) + let button = controls.firstMatch + func rowIsClear() -> Bool { + let window = app.windows.firstMatch, navigation = app.navigationBars["Settings"].firstMatch + guard controls.count == 1, button.exists, button.isEnabled, button.isHittable, + window.exists, navigation.exists + else { return false } + let region = window.frame.intersection(form.frame) + return !region.isNull && !region.isEmpty && region.contains(button.frame) + && button.frame.minY >= navigation.frame.maxY + 8 + && button.frame.maxY <= region.maxY - 8 + } + // A Settings List may not create a row until it scrolls into view. + // Reveal the exact owned control before requiring existence or tapping. + for _ in 0..<8 { + if rowIsClear() { break } + form.swipeUp() + } + XCTAssertEqual(controls.count, 1) + let clear = rowIsClear() + XCTAssertTrue(clear, "The Settings picker must clear the navigation and window boundaries") + guard clear else { return } + button.tap() + } + + func testBillingLoadingAndFailurePreserveAvailableChatAndDraft() { + launch() + XCTAssertTrue(app.buttons["entitlement.send"].isEnabled) + notice("images", containing: "unavailable") + for feature in ["speech", "webSearch"] { + XCTAssertEqual(app.buttons["entitlement.media." + feature].value as? String, "available") + } + waitLabel("entitlement.web-effective", "Effective web on") + scenario("loading", billing: "loading") + waitSendEnabled(true) + notice("dictation", containing: "still loading") + for feature in ["speech", "webSearch"] { + XCTAssertEqual(app.buttons["entitlement.media." + feature].value as? String, "available") + } + scenario("failed", billing: "failed") + waitSendEnabled(true) + for feature in ["speech", "webSearch"] { + XCTAssertEqual(app.buttons["entitlement.media." + feature].value as? String, "available") + } + XCTAssertFalse(app.staticTexts["Account access is unavailable. Your draft is preserved."].exists) + tap("entitlement.settings") + XCTAssertTrue( + app.staticTexts["Your plan could not be loaded. Try refreshing account status."].waitForExistence( + timeout: 5)) + XCTAssertFalse(app.staticTexts["Free Plan"].exists) + referenceScreenshot("failed read-only plan with chat still available") + tap("entitlement.settings.close") + scenario("pro", billing: "paid") + waitSendEnabled(true) + } + + func testFreeLockedModelsExplainAccessAndPaidMediaIgnoreModelCapabilities() { + launch(); scenario("free", billing: "Free") + waitSendEnabled(true) + tap("entitlement.models") + let quick = app.buttons["settings.model.auto:quick"] + let powerful = app.buttons["settings.model.auto:powerful"] + XCTAssertTrue(quick.waitForExistence(timeout: 5)) + XCTAssertTrue(quick.isEnabled) + XCTAssertTrue(powerful.isEnabled) + XCTAssertTrue(powerful.label.contains("Reasoning")) + XCTAssertTrue(powerful.label.contains("New")) + XCTAssertFalse(powerful.label.contains("Pro")) + XCTAssertFalse(app.buttons["settings.model.synthetic-disabled"].exists) + powerful.tap() + let message = app.staticTexts["media.access.message"] + XCTAssertTrue(message.waitForExistence(timeout: 5)) + XCTAssertEqual(message.label, "Get access to our most advanced AI models for superior performance") + XCTAssertTrue(app.navigationBars["Access Power target"].exists) + XCTAssertTrue(app.staticTexts["All models run in secure, encrypted environments"].exists) + XCTAssertFalse(app.buttons["Upgrade"].exists) + XCTAssertFalse(app.buttons["Buy"].exists) + referenceScreenshot("locked model read-only explanation with badges") + tap("media.access.close") + tap("entitlement.models.close") + waitLabel("entitlement.selected-model", "auto:quick") + notice("images", containing: "paid plan") + notice("dictation", containing: "paid plan") + notice("speech", containing: "paid plan") + XCTAssertEqual(app.buttons["entitlement.media.webSearch"].value as? String, "available") + notice("webSearch", containing: "available for your account") + waitLabel("entitlement.web-preference", "Web preference on") + waitLabel("entitlement.web-effective", "Effective web on") + scenario("pro", billing: "paid") + for feature in ["images", "dictation", "speech", "webSearch"] { + XCTAssertEqual(app.buttons["entitlement.media." + feature].value as? String, "available") + } + waitLabel("entitlement.web-effective", "Effective web on") + tap("entitlement.models"); tap("settings.model.synthetic-text") + waitLabel("entitlement.selected-model", "synthetic-text") + waitSendEnabled(true) + for feature in ["images", "dictation", "speech", "webSearch"] { + XCTAssertEqual(app.buttons["entitlement.media." + feature].value as? String, "available") + } + waitLabel("entitlement.web-preference", "Web preference on") + waitLabel("entitlement.web-effective", "Effective web on") + XCTAssertEqual(editor.value as? String, "Keep this unsent draft") + } + + func testOnlyKnownFreeGuestsAreBlockedAndUnknownPlansKeepImagesAndDictationUnavailable() { + launch(guest: true); scenario("free", billing: "Free") + waitSendEnabled(false) + XCTAssertTrue( + app.staticTexts["This guest account can’t start a chat. Sign in with an eligible account."].exists) + referenceScreenshot("existing guest chat restriction") + scenario("pro", billing: "paid") + waitSendEnabled(true) + scenario("seat", billing: "paid") + waitSendEnabled(true) + XCTAssertEqual(app.buttons["entitlement.media.images"].value as? String, "available") + scenario("usage", billing: "paid") + waitSendEnabled(true) + XCTAssertEqual(app.buttons["entitlement.media.dictation"].value as? String, "available") + scenario("unknownPlan", billing: "unknownPlan") + waitSendEnabled(true) + for feature in ["speech", "webSearch"] { + XCTAssertEqual(app.buttons["entitlement.media." + feature].value as? String, "available") + } + waitLabel("entitlement.web-effective", "Effective web on") + notice("images", containing: "plan details are unavailable") + notice("dictation", containing: "plan details are unavailable") + tap("entitlement.settings") + XCTAssertFalse( + app.staticTexts["Plan details are unavailable. Paid features remain unavailable until your plan is known."] + .exists) + XCTAssertEqual(app.staticTexts["settings.plan-name"].label, "Unrecognized fixture plan Plan") + for label in ["Upgrade", "Manage subscription", "Restore purchases", "Developer API", "Manage team"] { + XCTAssertFalse(app.buttons[label].exists) + } + referenceScreenshot("unknown read-only plan") + tap("entitlement.settings.close") + XCTAssertEqual(editor.value as? String, "Keep this unsent draft") + } + + func testSettingsRefreshRecoversBillingWithoutBlockingAvailableChat() { + launch(); scenario("failed", billing: "failed") + waitSendEnabled(true) + tap("entitlement.prepare-retry") + waitLabel("entitlement.retry-service", "service ready; account unchanged") + waitLabel("entitlement.billing", "failed") + tap("entitlement.settings") + tap("Refresh account status") + XCTAssertTrue(app.staticTexts["settings.plan-name"].waitForExistence(timeout: 10)) + XCTAssertEqual(app.staticTexts["settings.plan-name"].label, "Pro Annual Plan") + tap("entitlement.settings.close") + waitLabel("entitlement.billing", "paid") + waitSendEnabled(true) + XCTAssertEqual(editor.value as? String, "Keep this unsent draft") + } + + func testPlanDatesAndPreferenceDescriptionsMatchResearch() { + launch(); scenario("pro", billing: "paid") + tap("entitlement.settings") + XCTAssertEqual(app.staticTexts["settings.plan-name"].label, "Pro Annual Plan") + XCTAssertTrue(app.staticTexts["Renews on"].exists) + let fontDescription = app.staticTexts["settings.font-description"] + if !fontDescription.isHittable { app.swipeUp() } + XCTAssertEqual(fontDescription.label, "Maple's default, with a compact modern feel.") + tapSettingsRow("settings.voice") + XCTAssertTrue( + app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", "Default voices")).firstMatch + .exists) + XCTAssertTrue( + app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", "Reference accents")).firstMatch + .exists) + tapPickerChoice("Casual — Female") + tapSettingsRow("settings.speed") + tapPickerChoice("1.2×") + tap("entitlement.settings.close") + for id in ["pass", "zaprite"] { + scenario(id, billing: "paid") + tap("entitlement.settings") + XCTAssertEqual(app.staticTexts["settings.plan-name"].label, "Pro Annual Plan") + XCTAssertTrue(app.staticTexts["Expires on"].exists) + XCTAssertFalse(app.staticTexts["Renews on"].exists) + referenceScreenshot(id + " expiry label") + tap("entitlement.settings.close") + } + } + + func testThinkingOptionsFollowTheModelAndPreserveTheSavedChoice() { + launch(); scenario("free", billing: "Free") + tap("entitlement.models") + let thinking = app.buttons["model.thinking-level"] + XCTAssertTrue(thinking.waitForExistence(timeout: 5)) + XCTAssertEqual(thinking.value as? String, "Default") + thinking.tap() + referenceScreenshot("thinking native menu default subtitle") + let defaultChoice = app.buttons["model.thinking-level.default"] + let offChoice = app.buttons["model.thinking-level.none"] + XCTAssertTrue(defaultChoice.waitForExistence(timeout: 5)) + XCTAssertEqual(defaultChoice.label, "Default") + XCTAssertTrue(defaultChoice.isHittable && offChoice.isHittable) + // iOS renders the subtitle but exposes only the action title to XCTest. + // Keep its exact copy in the screenshot and require its native second + // line's layout; the missing-subtitle baseline has equal-height rows. + XCTAssertGreaterThan(defaultChoice.frame.height, offChoice.frame.height) + for label in ["Off", "Low", "High"] { + XCTAssertTrue( + app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", label)).firstMatch + .exists) + } + tapPickerChoice("High") + XCTAssertEqual(thinking.value as? String, "High") + tap("settings.model.synthetic-low") + waitLabel("entitlement.selected-model", "synthetic-low") + tap("entitlement.models") + XCTAssertEqual(thinking.value as? String, "Default") + thinking.tap() + for label in ["Off", "High"] { + XCTAssertFalse( + app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", label)).firstMatch.exists) + } + tapPickerChoice("Low") + XCTAssertEqual(thinking.value as? String, "Low") + tap("settings.model.auto:quick") + waitLabel("entitlement.selected-model", "auto:quick") + tap("entitlement.models") + XCTAssertEqual(thinking.value as? String, "Low") + thinking.tap(); tapPickerChoice("High") + tap("settings.model.synthetic-text") + waitLabel("entitlement.selected-model", "synthetic-text") + tap("entitlement.models") + XCTAssertFalse(thinking.exists) + tap("settings.model.auto:quick") + waitLabel("entitlement.selected-model", "auto:quick") + tap("entitlement.models") + XCTAssertEqual(thinking.value as? String, "High") + referenceScreenshot("thinking selection retained across non-reasoning model") + tap("entitlement.models.close") + } +} diff --git a/apps/maple-ios/UITests/LibraryControlsUITests.swift b/apps/maple-ios/UITests/LibraryControlsUITests.swift new file mode 100644 index 000000000..d4eeeb763 --- /dev/null +++ b/apps/maple-ios/UITests/LibraryControlsUITests.swift @@ -0,0 +1,514 @@ +import XCTest + +/// These cases never opt into the local account. The DEBUG fixture constructs +/// isolated in-memory services and invokes the shipping Library destinations. +@MainActor final class LibraryControlsUITests: XCTestCase { + private var app: XCUIApplication! + private func launch(_ extra: [String] = []) { + continueAfterFailure = false + app = XCUIApplication(); app.launchArguments = ["--maple-library-controls-fixture"] + extra; app.launch() + label("library.fixture.ready", equals: "ready") + } + private func label(_ id: String, equals value: String, timeout: TimeInterval = 15) { + let element = app.staticTexts[id] + XCTAssertTrue(element.waitForExistence(timeout: timeout)) + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "label == %@", value), object: element) + XCTAssertEqual(XCTWaiter.wait(for: [expectation], timeout: timeout), .completed) + } + private func tap(_ identifier: String) { + let button = app.buttons[identifier].firstMatch + XCTAssertTrue(button.waitForExistence(timeout: 15)) + for _ in 0..<4 where !button.isHittable { app.swipeUp() } + XCTAssertTrue(button.isHittable); button.tap() + } + private func openHistory() { + tap("library.fixture.history") + XCTAssertTrue(app.buttons["library.chat.alpha"].waitForExistence(timeout: 15)) + } + private func selectAlphaAndBeta() { + tap("Alpha chat"); tap("Beta chat") + } + private func openDeleteAll() { tap("library.actions"); tap("Delete all chats") } + private func confirmTypedDelete() { + let field = app.textFields["library.delete-confirmation"] + XCTAssertTrue(field.waitForExistence(timeout: 10)) + XCTAssertFalse(app.buttons["library.delete-confirm"].isEnabled) + field.tap(); field.typeText("DELETE"); tap("library.delete-confirm") + } + private func openProjectDeletion() { + tap("library.fixture.project") + XCTAssertTrue(app.buttons["library.chat.active"].waitForExistence(timeout: 15)) + tap("Project actions"); tap("Delete project") + XCTAssertTrue(app.navigationBars["Delete this project?"].waitForExistence(timeout: 10)) + XCTAssertEqual(app.buttons["library.delete-confirm"].label, "Delete project") + } + private func openProjectDetail(_ id: String = "fixture-project") { + let folder = app.buttons["library.project." + id] + XCTAssertTrue(folder.waitForExistence(timeout: 15) && folder.isHittable) + folder.press(forDuration: 0.7) + tap("library.project.view." + id) + XCTAssertTrue(app.buttons["library.project-detail.instructions"].waitForExistence(timeout: 15)) + } + private func screenshot(_ name: String) { + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = name; attachment.lifetime = .keepAlways; add(attachment) + } + private func dragAcknowledgement(_ expected: String) { + let state = app.staticTexts["library.fixture.alpha-state"] + XCTAssertTrue(state.waitForExistence(timeout: 15)) + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "label == %@", expected), object: state) + let result = XCTWaiter.wait(for: [expectation], timeout: 15) + let trace = XCTAttachment(string: app.staticTexts["library.fixture.drag-trace"].label) + trace.name = "Content-free native drag delegate events"; trace.lifetime = .keepAlways; add(trace) + if result != .completed { screenshot("Native drag missing expected acknowledgement") } + XCTAssertEqual(result, .completed) + } + + func testHistoryPaginationFailureRetainsRowsAndRetryLoadsNextPageOnce() { + launch(["--maple-library-controls-page-failure"]); openHistory() + XCTAssertFalse(app.buttons["library.chat.outside"].exists) + tap("Load more") + XCTAssertTrue(app.staticTexts["library.error"].waitForExistence(timeout: 15)) + XCTAssertTrue(app.buttons["library.chat.alpha"].exists) + XCTAssertTrue(app.buttons["library.chat.beta"].exists) + tap("Load more") + XCTAssertTrue(app.buttons["library.chat.outside"].waitForExistence(timeout: 15)) + XCTAssertEqual(app.buttons.matching(identifier: "library.chat.alpha").count, 1) + XCTAssertEqual(app.buttons.matching(identifier: "library.chat.active").count, 1) + XCTAssertFalse(app.buttons["Load more"].exists) + label("library.fixture.pages", equals: "pages 3") + XCTAssertFalse(app.staticTexts["library.error"].exists) + screenshot("History pagination retry preserves previous rows") + } + + func testMultiSelectionMovesAndDeletesOnlySelectedChats() { + launch(); openHistory() + // This fixture intentionally returns two chats per page. Load the + // retained rows before asserting their controls survive bulk actions. + tap("Load more") + XCTAssertTrue(app.buttons["library.chat.active"].waitForExistence(timeout: 15)) + tap("library.actions"); tap("Select chats") + selectAlphaAndBeta(); tap("Move selected chats"); tap("Synthetic project") + label("library.fixture.moved", equals: "alpha,beta to fixture-project") + XCTAssertFalse(app.buttons["Move selected chats"].exists) + XCTAssertFalse(app.buttons["library.selection-done"].exists) + tap("library.actions"); tap("Select chats") + selectAlphaAndBeta(); tap("Delete selected chats") + XCTAssertTrue(app.staticTexts["Delete 2 chats?"].waitForExistence(timeout: 10)) + let confirmationReady = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.app.buttons.matching(identifier: "Delete selected chats").allElementsBoundByIndex.contains { + $0.isHittable + } + }, object: app) + XCTAssertEqual(XCTWaiter.wait(for: [confirmationReady], timeout: 10), .completed) + guard + let confirm = app.buttons.matching(identifier: "Delete selected chats").allElementsBoundByIndex.first( + where: { $0.isHittable }) + else { + XCTFail("The native deletion confirmation is not hittable"); return + } + confirm.tap() + label("library.fixture.deleted", equals: "alpha,beta") + label("library.fixture.remaining", equals: "Remaining active,outside") + label("library.fixture.project-state", equals: "project retained") + XCTAssertFalse(app.buttons["Delete selected chats"].exists) + XCTAssertFalse(app.buttons["library.selection-done"].exists) + let retainedActions = app.buttons["library.chat-actions.active"] + XCTAssertTrue(retainedActions.waitForExistence(timeout: 15)) + XCTAssertTrue(retainedActions.isHittable) + screenshot("Bulk selection preserves unrelated chats and project") + } + + func testVisibleChatActionsSelectExactChatAndDoneClearsSelection() { + launch(); openHistory(); tap("library.chat-actions.alpha") + for action in ["Select", "Pin", "Move to project", "Rename", "Delete"] { + XCTAssertTrue(app.buttons[action].waitForExistence(timeout: 10)) + } + tap("Select") + XCTAssertTrue(app.buttons["library.chat.alpha"].isSelected) + XCTAssertFalse(app.buttons["library.chat.beta"].isSelected) + XCTAssertTrue(app.buttons["Move selected chats"].isEnabled) + tap("library.selection-done") + XCTAssertFalse(app.buttons["Move selected chats"].exists) + XCTAssertTrue(app.buttons["library.chat-actions.alpha"].isHittable) + tap("library.actions"); tap("Select chats") + XCTAssertFalse(app.buttons["library.chat.alpha"].isSelected) + XCTAssertFalse(app.buttons["Move selected chats"].isEnabled) + tap("library.selection-done") + screenshot("Visible Done exits selection without retaining selected chats") + } + + func testVisibleChatActionsPinAndUnpinRetainLongPressActions() { + launch(); openHistory(); tap("library.chat-actions.alpha"); tap("Pin") + let actions = app.buttons["library.chat-actions.alpha"] + let ready = XCTNSPredicateExpectation(predicate: NSPredicate(format: "enabled == true"), object: actions) + XCTAssertEqual(XCTWaiter.wait(for: [ready], timeout: 10), .completed) + tap("library.chat-actions.alpha") + XCTAssertTrue(app.buttons["Unpin"].waitForExistence(timeout: 10)); tap("Unpin") + let unpinned = XCTNSPredicateExpectation(predicate: NSPredicate(format: "enabled == true"), object: actions) + XCTAssertEqual(XCTWaiter.wait(for: [unpinned], timeout: 10), .completed) + app.buttons["library.chat.alpha"].press(forDuration: 1) + XCTAssertTrue(app.buttons["Pin"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.buttons["Select"].exists) + screenshot("Ellipsis and long press share chat actions after pinning and unpinning") + } + + func testChatSwipesNeverPinOrDeleteWithoutExplicitConfirmation() { + launch(["--maple-library-menu-proof"]) + let alpha = app.buttons["library.chat.alpha"] + XCTAssertTrue(alpha.waitForExistence(timeout: 15)) + alpha.swipeRight() + XCTAssertFalse(app.buttons["Pin"].exists) + label("library.fixture.deletion-count", equals: "deletions 0") + + // A full swipe may reveal controls, but must not invoke deletion. + alpha.coordinate(withNormalizedOffset: CGVector(dx: 0.95, dy: 0.5)) + .press( + forDuration: 0.02, + thenDragTo: alpha.coordinate(withNormalizedOffset: CGVector(dx: 0.02, dy: 0.5))) + let delete = app.buttons["library.swipe-delete.alpha"] + XCTAssertTrue(delete.waitForExistence(timeout: 10)) + XCTAssertFalse(app.alerts["Are you sure you want to delete this chat?"].exists) + label("library.fixture.remaining", equals: "Remaining alpha,beta,active,outside") + label("library.fixture.deletion-count", equals: "deletions 0") + delete.tap() + let confirmation = app.alerts["Are you sure you want to delete this chat?"] + XCTAssertTrue(confirmation.waitForExistence(timeout: 10)) + XCTAssertTrue(confirmation.buttons["Cancel"].isHittable) + label("library.fixture.remaining", equals: "Remaining alpha,beta,active,outside") + label("library.fixture.deletion-count", equals: "deletions 0") + screenshot("Swipe Delete waits for an explicit confirmation") + confirmation.buttons["Cancel"].tap() + XCTAssertTrue(alpha.isHittable) + label("library.fixture.remaining", equals: "Remaining alpha,beta,active,outside") + label("library.fixture.deletion-count", equals: "deletions 0") + + alpha.swipeLeft() + XCTAssertTrue(delete.waitForExistence(timeout: 10)); delete.tap() + XCTAssertTrue(confirmation.waitForExistence(timeout: 10)) + confirmation.buttons["Delete"].tap() + label("library.fixture.deleted", equals: "alpha") + label("library.fixture.remaining", equals: "Remaining beta,active,outside") + label("library.fixture.events", equals: "delete-selected") + label("library.fixture.deletion-count", equals: "deletions 1") + XCTAssertFalse(alpha.exists) + XCTAssertTrue(app.buttons["library.chat.beta"].isHittable) + screenshot("Only the confirmed chat is removed after the swipe action") + } + + func testEllipsisDeleteAlsoRequiresConfirmationAndCancelKeepsTheChat() { + launch(["--maple-library-menu-proof"]); tap("library.chat-actions.alpha"); tap("Delete") + let confirmation = app.alerts["Are you sure you want to delete this chat?"] + XCTAssertTrue(confirmation.waitForExistence(timeout: 10)) + label("library.fixture.remaining", equals: "Remaining alpha,beta,active,outside") + label("library.fixture.deletion-count", equals: "deletions 0") + confirmation.buttons["Cancel"].tap() + XCTAssertTrue(app.buttons["library.chat.alpha"].isHittable) + label("library.fixture.remaining", equals: "Remaining alpha,beta,active,outside") + label("library.fixture.deletion-count", equals: "deletions 0") + tap("library.chat-actions.alpha"); tap("Delete") + XCTAssertTrue(confirmation.waitForExistence(timeout: 10)) + confirmation.buttons["Delete"].tap() + label("library.fixture.deleted", equals: "alpha") + label("library.fixture.remaining", equals: "Remaining beta,active,outside") + label("library.fixture.events", equals: "delete-selected") + label("library.fixture.deletion-count", equals: "deletions 1") + } + + func testNativeChatDragMovesBetweenProjectsPinnedAndRecent() { + launch(["--maple-library-drag-proof"]) + let alpha = app.buttons["library.chat.alpha"] + let first = app.buttons["library.project.fixture-project"] + let second = app.buttons["library.project.fixture-project-2"] + let pinned = app.staticTexts["library.drop.pinned"] + XCTAssertTrue(alpha.waitForExistence(timeout: 15)) + XCTAssertTrue(first.isHittable && second.isHittable && alpha.isHittable) + XCTAssertFalse(pinned.exists) + // Establish a visible destination through the shipping chat actions. + // XCTest resolves drag endpoints before a lift, so a target inserted + // during the gesture cannot be located by its later accessibility frame. + tap("library.chat-actions.beta"); tap("Pin") + XCTAssertTrue(pinned.waitForExistence(timeout: 10) && pinned.isHittable && alpha.isHittable) + alpha.press(forDuration: 0.8, thenDragTo: first, withVelocity: .slow, thenHoldForDuration: 0.3) + dragAcknowledgement("alpha:fixture-project:unpinned") + tap("library.project.fixture-project") + XCTAssertTrue(alpha.waitForExistence(timeout: 10) && alpha.isHittable) + XCTAssertGreaterThanOrEqual(alpha.frame.minX, first.frame.minX + 28) + XCTAssertGreaterThanOrEqual(alpha.frame.height, 50 - 0.01) + alpha.press(forDuration: 0.8, thenDragTo: second, withVelocity: .slow, thenHoldForDuration: 0.3) + dragAcknowledgement("alpha:fixture-project-2:unpinned") + tap("library.project.fixture-project-2") + XCTAssertTrue(alpha.waitForExistence(timeout: 10) && alpha.isHittable) + + let recent = app.staticTexts["library.drop.recent"] + XCTAssertTrue(pinned.isHittable) + alpha.press(forDuration: 0.8, thenDragTo: pinned, withVelocity: .slow, thenHoldForDuration: 0.3) + dragAcknowledgement("alpha:fixture-project-2:pinned") + XCTAssertEqual(app.buttons.matching(identifier: "library.chat.alpha").count, 1) + XCTAssertTrue(pinned.isHittable && recent.isHittable) + alpha.press(forDuration: 0.8, thenDragTo: recent, withVelocity: .slow, thenHoldForDuration: 0.3) + dragAcknowledgement("alpha:recent:unpinned") + XCTAssertEqual(app.buttons.matching(identifier: "library.chat.alpha").count, 1) + tap("library.chat-actions.beta"); tap("Unpin") + let emptyPinnedGone = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "exists == false"), object: pinned) + XCTAssertEqual(XCTWaiter.wait(for: [emptyPinnedGone], timeout: 10), .completed) + screenshot("Native drag moves exact chat between folders, Pinned and Recent") + } + + func testDeleteAllRequiresTypedIntentAndIncludesUnloadedChatsButKeepsProjects() { + launch(); openHistory(); openDeleteAll() + let field = app.textFields["library.delete-confirmation"] + XCTAssertTrue(field.waitForExistence(timeout: 10)); field.tap(); field.typeText("DELET") + XCTAssertFalse(app.buttons["library.delete-confirm"].isEnabled) + tap("Cancel") + label("library.fixture.remaining", equals: "Remaining alpha,beta,active,outside") + openDeleteAll(); confirmTypedDelete() + label("library.fixture.remaining", equals: "Remaining none") + label("library.fixture.project-state", equals: "project retained") + label("library.fixture.events", equals: "delete-all") + XCTAssertTrue(app.staticTexts["No chats yet"].waitForExistence(timeout: 10)) + screenshot("Delete all covers unloaded chats and preserves projects") + } + + func testProjectDeletionCancelsExactActiveResponseBeforeBackendMutation() { + launch(["--maple-library-controls-active"]); openProjectDeletion(); confirmTypedDelete() + label("library.fixture.events", equals: "post|cancel-request|cancel-confirm|delete-project") + label("library.fixture.remaining", equals: "Remaining alpha,beta,outside") + label("library.fixture.project-state", equals: "project deleted") + label("library.fixture.owner", equals: "removed") + label("library.fixture.draft", equals: "Draft empty") + screenshot("Active project deletion waits for cancellation and retires owner") + } + + func testFailedActiveProjectDeletionKeepsDraftAndQueuePausedForRetry() { + launch(["--maple-library-controls-active", "--maple-library-controls-delete-failure"]) + openProjectDeletion(); confirmTypedDelete() + XCTAssertTrue(app.staticTexts["library.error"].waitForExistence(timeout: 15)) + tap("Cancel") + label("library.fixture.events", equals: "post|cancel-request|cancel-confirm|delete-failed") + label("library.fixture.owner", equals: "retained") + label("library.fixture.queue", equals: "paused") + label("library.fixture.queue-items", equals: "Synthetic queued request") + label("library.fixture.draft", equals: "Draft Unsent draft survives a failed deletion") + label("library.fixture.remaining", equals: "Remaining alpha,beta,active,outside") + tap("Project actions"); tap("Delete project"); confirmTypedDelete() + label("library.fixture.events", equals: "post|cancel-request|cancel-confirm|delete-failed|delete-project") + label("library.fixture.owner", equals: "removed") + label("library.fixture.remaining", equals: "Remaining alpha,beta,outside") + } + + func testRunningUnreadRowsAndCollapsedProjectSummaryClearOnSelection() { + launch(["--maple-library-parity-proof"]) + let alpha = app.buttons["library.chat.alpha"] + XCTAssertTrue(alpha.waitForExistence(timeout: 15)) + XCTAssertEqual(alpha.label, "Alpha chat, running") + XCTAssertEqual(app.buttons["library.chat.beta"].label, "Beta chat, completed, unread") + let folder = app.buttons["library.project.fixture-project"] + XCTAssertEqual(folder.label, "Synthetic project, completed, unread") + XCTAssertGreaterThanOrEqual(folder.frame.width, 44) + XCTAssertGreaterThanOrEqual(folder.frame.height, 44) + XCTAssertEqual(folder.value as? String, "Collapsed") + XCTAssertFalse(app.buttons["library.project.expand.fixture-project"].exists) + tap("library.project.fixture-project") + let child = app.buttons["library.chat.active"] + XCTAssertTrue(child.waitForExistence(timeout: 15)) + XCTAssertEqual(child.label, "Active project chat, completed, unread") + XCTAssertEqual(folder.label, "Synthetic project") + XCTAssertEqual(folder.value as? String, "Expanded") + child.tap() + XCTAssertEqual(child.label, "Active project chat") + tap("library.project.fixture-project") + XCTAssertEqual(folder.label, "Synthetic project") + tap("library.chat.beta") + XCTAssertEqual(app.buttons["library.chat.beta"].label, "Beta chat") + XCTAssertEqual(alpha.label, "Alpha chat, running") + screenshot("Running and unread rows with collapsed project summary") + } + + func testProjectMenuOpensScopedDetailWithInstructionsAndSelection() { + launch(["--maple-library-parity-proof"]) + openProjectDetail() + let instructions = app.buttons["library.project-detail.instructions"] + XCTAssertTrue(instructions.waitForExistence(timeout: 15)) + let preview = XCTNSPredicateExpectation( + predicate: NSPredicate( + format: "label CONTAINS %@", "Set shared custom instructions that apply to chats in this project."), + object: instructions) + XCTAssertEqual(XCTWaiter.wait(for: [preview], timeout: 15), .completed) + XCTAssertTrue(app.buttons["library.chat.active"].exists) + XCTAssertFalse(app.buttons["library.chat.alpha"].exists) + XCTAssertEqual(app.buttons["library.project-detail.new-chat"].label, "New chat in Synthetic project") + tap("library.project-detail.actions"); tap("Select chats") + tap("library.chat.active") + XCTAssertTrue(app.buttons["library.chat.active"].isSelected) + XCTAssertTrue(app.buttons["Move selected chats"].isEnabled) + XCTAssertTrue(app.buttons["Delete"].isEnabled) + let back = app.navigationBars.buttons.firstMatch + XCTAssertTrue(back.waitForExistence(timeout: 10)); back.tap() + openProjectDetail() + tap("library.project-detail.actions"); tap("Select chats") + XCTAssertFalse(app.buttons["library.chat.active"].isSelected) + XCTAssertFalse(app.buttons["Move selected chats"].isEnabled) + tap("library.selection-done") + tap("library.project-detail.instructions") + XCTAssertTrue(app.textViews["library.instructions-text"].waitForExistence(timeout: 15)) + XCTAssertTrue(app.staticTexts["Leave this empty to clear the project's custom instructions."].exists) + tap("library.instructions-cancel") + XCTAssertTrue(app.buttons["library.project-detail.instructions"].waitForExistence(timeout: 10)) + screenshot("Project detail uses shared instructions and selection controls") + } + + func testProjectDetailMutationRestoresTheRetainedSidebarSearchOnBack() { + launch(["--maple-library-parity-proof", "--maple-library-search-proof"]) + let menuSearch = app.textFields["library.search"] + XCTAssertTrue(menuSearch.waitForExistence(timeout: 10)) + menuSearch.tap(); menuSearch.typeText("alpha\n") + XCTAssertTrue(app.buttons["library.chat.project-alpha"].waitForExistence(timeout: 15)) + XCTAssertFalse(app.buttons["library.chat.project-beta"].exists) + XCTAssertEqual(menuSearch.value as? String, "alpha") + + openProjectDetail() + let beta = app.buttons["library.chat.project-beta"] + XCTAssertTrue(beta.waitForExistence(timeout: 15)) + tap("library.chat-actions.project-beta"); tap("Rename") + let name = app.textFields["library.rename-title"] + XCTAssertTrue(name.waitForExistence(timeout: 10)) + name.typeText(" renamed"); tap("library.rename-save") + let renamed = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "label CONTAINS %@", "renamed"), object: beta) + XCTAssertEqual(XCTWaiter.wait(for: [renamed], timeout: 15), .completed) + let renameDismissed = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "exists == false"), object: name) + XCTAssertEqual(XCTWaiter.wait(for: [renameDismissed], timeout: 10), .completed) + + let back = app.navigationBars.buttons.firstMatch + XCTAssertTrue(back.waitForExistence(timeout: 10) && back.isHittable); back.tap() + XCTAssertTrue(app.buttons["library.chat.project-alpha"].waitForExistence(timeout: 15)) + XCTAssertEqual(menuSearch.value as? String, "alpha") + XCTAssertFalse(beta.exists) + screenshot("Project mutation preserves the visible sidebar query and filtered children") + } + + func testLibraryProjectCreationOpensDetailAndUnchangedRenamesDoNotMutate() { + launch(["--maple-library-parity-proof"]) + tap("library.chat-actions.alpha"); tap("Rename") + XCTAssertTrue(app.textFields["library.rename-title"].waitForExistence(timeout: 10)) + tap("library.rename-save") + label("library.rename-validation", equals: "Please enter a different title.") + tap("Cancel") + tap("library.new-project") + let name = app.textFields["project-editor.name"] + XCTAssertTrue(name.waitForExistence(timeout: 10)); name.tap(); name.typeText("New synthetic project") + tap("project-editor.save") + XCTAssertTrue(app.buttons["library.project-detail.instructions"].waitForExistence(timeout: 15)) + XCTAssertTrue(app.navigationBars["New synthetic project"].exists) + XCTAssertTrue(app.staticTexts["No chats in this project yet."].waitForExistence(timeout: 15)) + XCTAssertEqual(app.buttons["library.project-detail.new-chat"].label, "New chat in New synthetic project") + tap("library.project-detail.actions"); tap("Rename project") + XCTAssertTrue(app.textFields["project-editor.name"].waitForExistence(timeout: 10)) + tap("project-editor.save") + label("project-editor.validation", equals: "Please enter a different project name.") + tap("project-editor.cancel") + label("library.fixture.events", equals: "create-project") + screenshot("Library creation opens project detail and unchanged names are rejected") + } + + func testReadOnlyUsageMovesToSettingsAndTeamSeatNoticeStaysInLibrary() { + launch(["--maple-library-account-proof"]) + let usage = app.descendants(matching: .any).matching(identifier: "settings.usage").firstMatch + XCTAssertFalse(usage.exists) + XCTAssertFalse(app.descendants(matching: .any).matching(identifier: "library.usage").firstMatch.exists) + let banner = app.descendants(matching: .any).matching(identifier: "chat.team-seat-mismatch").firstMatch + XCTAssertTrue(banner.waitForExistence(timeout: 15)) + XCTAssertTrue(banner.label.contains("1 member, 0 paid seats"), banner.label) + XCTAssertTrue(banner.label.contains("This team has 1 member but only 0 paid seats."), banner.label) + XCTAssertTrue(banner.label.contains("Remove members to match the paid seat count and resume team usage.")) + let newChat = app.buttons["library.new-chat"] + XCTAssertTrue(newChat.waitForExistence(timeout: 10)) + let noticeFrame = banner.frame, newChatFrame = newChat.frame + XCTAssertGreaterThan(noticeFrame.height, 0) + XCTAssertGreaterThanOrEqual(newChatFrame.height, 44) + XCTAssertGreaterThanOrEqual( + newChatFrame.minY, noticeFrame.maxY - 0.5, + "The initial New chat control must stay below the fixed seat notice without scrolling") + XCTAssertTrue(newChat.isHittable) + XCTAssertFalse(app.buttons["Manage Team"].exists) + XCTAssertFalse(app.buttons["Pricing"].exists) + screenshot("Library keeps its singular seat notice with no usage tile") + + openProjectDetail() + let projectBanner = app.descendants(matching: .any).matching(identifier: "chat.team-seat-mismatch").firstMatch + XCTAssertTrue(projectBanner.waitForExistence(timeout: 10)) + XCTAssertTrue(projectBanner.label.contains("1 member, 0 paid seats"), projectBanner.label) + let projectNewChat = app.buttons["library.project-detail.new-chat"] + XCTAssertTrue(projectNewChat.waitForExistence(timeout: 10)) + let projectNoticeFrame = projectBanner.frame, projectNewChatFrame = projectNewChat.frame + XCTAssertGreaterThan(projectNoticeFrame.height, 0) + XCTAssertGreaterThanOrEqual(projectNewChatFrame.height, 44) + XCTAssertGreaterThanOrEqual( + projectNewChatFrame.minY, projectNoticeFrame.maxY - 0.5, + "Project New chat must stay below the fixed seat notice without scrolling") + XCTAssertTrue(projectNewChat.isHittable) + screenshot("Project detail keeps the seat notice above its New chat control") + + tap("library.project-detail.instructions") + let instructions = app.textViews["library.instructions-text"] + XCTAssertTrue(instructions.waitForExistence(timeout: 10)) + let instructionBanner = app.descendants(matching: .any).matching(identifier: "chat.team-seat-mismatch") + .firstMatch + XCTAssertTrue(instructionBanner.waitForExistence(timeout: 10)) + XCTAssertTrue(instructionBanner.label.contains("1 member, 0 paid seats"), instructionBanner.label) + let instructionNoticeFrame = instructionBanner.frame, instructionsFrame = instructions.frame + XCTAssertGreaterThan(instructionNoticeFrame.height, 0) + XCTAssertGreaterThanOrEqual(instructionsFrame.height, 44) + XCTAssertGreaterThanOrEqual( + instructionsFrame.minY, instructionNoticeFrame.maxY - 0.5, + "Custom Instructions must stay below the fixed seat notice") + XCTAssertTrue(instructions.isHittable) + screenshot("Custom Instructions preserves the seat notice above the editor") + tap("library.instructions-cancel") + XCTAssertTrue(projectNewChat.waitForExistence(timeout: 10) && projectNewChat.isHittable) + let returnedProjectBanner = app.descendants(matching: .any).matching(identifier: "chat.team-seat-mismatch") + .firstMatch + XCTAssertTrue(returnedProjectBanner.waitForExistence(timeout: 10)) + + let back = app.navigationBars.buttons.firstMatch + XCTAssertTrue(back.waitForExistence(timeout: 10) && back.isHittable); back.tap() + XCTAssertTrue(newChat.waitForExistence(timeout: 10) && newChat.isHittable) + let returnedRootBanner = app.descendants(matching: .any).matching(identifier: "chat.team-seat-mismatch") + .firstMatch + XCTAssertTrue(returnedRootBanner.waitForExistence(timeout: 10)) + XCTAssertGreaterThanOrEqual(newChat.frame.minY, returnedRootBanner.frame.maxY - 0.5) + + tap("library.actions") + let settings = app.buttons["library.settings"] + XCTAssertTrue(settings.waitForExistence(timeout: 10)) + XCTAssertTrue(["Settings", "Settings, Team usage paused"].contains(settings.label)) + let semantics = XCTAttachment( + string: "Settings label: \(settings.label)\nValue: \(String(describing: settings.value))\n" + + settings.debugDescription) + semantics.name = "Native Settings menu accessibility export"; semantics.lifetime = .keepAlways; add(semantics) + screenshot("Native Settings menu retains the simple title and colored attention icon") + settings.tap() + let form = app.collectionViews["settings.screen"] + XCTAssertTrue(form.waitForExistence(timeout: 10)) + let settingsBanner = app.descendants(matching: .any).matching(identifier: "chat.team-seat-mismatch").firstMatch + XCTAssertTrue(settingsBanner.waitForNonExistence(timeout: 10)) + for _ in 0..<6 where !usage.exists || !usage.isHittable { form.swipeUp() } + XCTAssertTrue(usage.waitForExistence(timeout: 10) && usage.isHittable) + XCTAssertTrue(app.staticTexts["Usage"].exists) + for text in ["Pro Plan", "25% used", "Resets Monthly", "+2.5 credits"] { + XCTAssertTrue(usage.label.localizedCaseInsensitiveContains(text), usage.label) + } + XCTAssertFalse(app.buttons["settings.usage"].exists) + XCTAssertEqual(usage.buttons.count, 0) + XCTAssertFalse(app.buttons["Manage Team"].exists) + XCTAssertFalse(app.buttons["Pricing"].exists) + XCTAssertFalse(app.descendants(matching: .any).matching(identifier: "library.usage").firstMatch.exists) + screenshot("Shipping Settings Usage section has plan, usage, reset and credit balance") + } +} diff --git a/apps/maple-ios/UITests/LibraryWorkflowUITests.swift b/apps/maple-ios/UITests/LibraryWorkflowUITests.swift new file mode 100644 index 000000000..32e628b66 --- /dev/null +++ b/apps/maple-ios/UITests/LibraryWorkflowUITests.swift @@ -0,0 +1,521 @@ +import Foundation +import XCTest + +/// Run only against the dedicated local account already signed into this exact +/// simulator. Each test changes its own uniquely named resources; never delete all. +@MainActor final class LibraryWorkflowUITests: XCTestCase { + private var app: XCUIApplication! + private var currentProjectID: String? + private var currentChatID: String? + private var cleanupRegistered = false + private var observedCleanupOwner: String? + + private func launchLocal() throws { + try XCTSkipUnless(ProcessInfo.processInfo.environment["MAPLE_RUN_LOCAL_UI"] == "1") + continueAfterFailure = false + app = XCUIApplication(); app.launchArguments = ["--maple-library-local-cleanup"]; app.launch() + XCTAssertTrue(app.buttons["chat.new"].waitForExistence(timeout: 30)) + observedCleanupOwner = try accountMarker() + try writeJournal(ownedJournal()) + if !cleanupRegistered { + cleanupRegistered = true + let cleanup: @MainActor @Sendable () throws -> Void = { [weak self] in + guard let self else { return } + try self.cleanupJournaledEntities() + } + addTeardownBlock { try await cleanup() } + } + // A previous interrupted test must be cleaned before creating another resource. + try cleanupJournaledEntities() + } + private func referenceScreenshot(_ surface: String) { + let capture = XCTAttachment(screenshot: app.screenshot()) + capture.name = "Reference — actual local Library — " + surface + capture.lifetime = .keepAlways; add(capture) + } + private func uniqueName(_ suffix: String) -> String { + let value = "Native UI \(UUID().uuidString.prefix(8)) \(suffix)" + let record = XCTAttachment(string: value) + record.name = "Synthetic local resource for exact cleanup"; record.lifetime = .keepAlways; add(record) + return value + } + private func tap(_ label: String) { + let button = label == "chat.menu" ? app.mapleMenuButton : app.buttons[label].firstMatch + XCTAssertTrue(button.waitForExistence(timeout: 15)) + for _ in 0..<5 where !button.isHittable { app.swipeUp() } + XCTAssertTrue(button.isHittable); button.tap() + } + private func replace(_ field: XCUIElement, with text: String, presentedAlert: XCUIElement? = nil) throws { + try replaceSyntheticText(field, with: text, in: app, presentedAlert: presentedAlert) + } + private func openMenu() { + tap("chat.menu"); XCTAssertTrue(app.buttons["library.new-project"].waitForExistence(timeout: 20)) + } + private func createProject(_ name: String) throws { + tap("library.new-project") + let field = app.textFields["project-editor.name"] + XCTAssertTrue(field.waitForExistence(timeout: 10)) + XCTAssertFalse(app.buttons["project-editor.save"].isEnabled) + field.tap(); field.typeText(name) + referenceScreenshot("new-project form") + tap("project-editor.save") + // Library creation now opens the real scoped detail. Journal its exact + // returned UUID before navigating back or asserting later controls. + let detail = app.descendants(matching: .any).matching( + NSPredicate(format: "identifier BEGINSWITH %@", "library.project-detail.") + ).matching( + NSPredicate( + format: "identifier != %@ AND identifier != %@ AND identifier != %@", + "library.project-detail.instructions", "library.project-detail.actions", + "library.project-detail.new-chat") + ).firstMatch + XCTAssertTrue(detail.waitForExistence(timeout: 20)) + let id = String(detail.identifier.dropFirst("library.project-detail.".count)) + guard UUID(uuidString: id) != nil else { throw LibraryCleanupIssue.invalidJournal } + try rememberProject(id); currentProjectID = id + let back = app.navigationBars.buttons.firstMatch + XCTAssertTrue(back.waitForExistence(timeout: 10)); back.tap() + XCTAssertTrue(app.buttons["library.project." + id].waitForExistence(timeout: 20)) + XCTAssertTrue(app.staticTexts[name].firstMatch.waitForExistence(timeout: 20)) + } + private func openProject(_ name: String) { + let owner = app.staticTexts["library.cleanup-account"] + guard let currentProjectID, UUID(uuidString: currentProjectID) != nil, + let observedCleanupOwner, owner.exists, owner.label == observedCleanupOwner + else { + XCTFail("The current project and account owner must remain available") + return + } + let title = app.buttons.matching(identifier: "library.project." + currentProjectID) + .matching(NSPredicate(format: "label == %@", name)).firstMatch + XCTAssertTrue(title.waitForExistence(timeout: 20)) + for _ in 0..<4 where !title.isHittable { app.swipeDown() } + XCTAssertTrue(title.isHittable) + title.press(forDuration: 1) + let manage = app.buttons["library.project.manage." + currentProjectID].firstMatch + XCTAssertTrue(manage.waitForExistence(timeout: 15)) + for _ in 0..<5 where !manage.isHittable { app.swipeUp() } + XCTAssertTrue(manage.isHittable); manage.tap() + XCTAssertTrue(app.navigationBars[name].waitForExistence(timeout: 15)) + } + private func deleteCurrentProject() { + tap("project-editor.delete") + let confirmation = app.textFields["library.delete-confirmation"] + XCTAssertTrue(confirmation.waitForExistence(timeout: 10)) + XCTAssertFalse(app.buttons["library.delete-confirm"].isEnabled) + referenceScreenshot("project deletion warning before confirmation") + confirmation.tap(); confirmation.typeText("DELETE"); tap("library.delete-confirm") + let deleted = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.app.buttons["library.new-project"].isHittable + && self.currentProjectID.map { !self.app.buttons["library.project." + $0].exists } == true + }, object: app) + XCTAssertEqual(XCTWaiter.wait(for: [deleted], timeout: 30), .completed) + XCTAssertFalse(app.staticTexts["library.error"].exists) + if let currentProjectID { + do { try forgetProject(currentProjectID) } catch { + XCTFail("Could not update exact cleanup journal: \(error)") + } + } + } + private func row(named title: String) -> XCUIElement { + app.buttons.matching( + NSPredicate(format: "identifier BEGINSWITH %@ AND label CONTAINS %@", "library.chat.", title) + ).firstMatch + } + + private func dismissChatOptionsWithSelectedModel() throws { + // Re-select the existing model to verify dismissal without changing the + // account preference. The real-root acceptance tests outside dismissal. + let models = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH %@", "settings.model.")) + guard let selected = models.allElementsBoundByIndex.first(where: { $0.isSelected && $0.isEnabled }) else { + throw LibraryCleanupIssue.inaccessibleControl + } + for _ in 0..<5 where !selected.isHittable { app.swipeUp() } + guard selected.isHittable else { throw LibraryCleanupIssue.inaccessibleControl } + selected.tap() + try cleanupWait { + !self.app.buttons["chat-options.project"].exists && self.app.buttons["chat.model"].isHittable + } + } + + private func openChatActions(_ id: String) throws { + guard UUID(uuidString: id) != nil else { throw LibraryCleanupIssue.invalidJournal } + let projects = try ownedJournal().projectIDs.sorted() + guard try MenuChatUI.find(id, in: app, projectIDs: projects, pageLimit: 20) != nil else { + throw LibraryCleanupIssue.inaccessibleControl + } + try cleanupTap("library.chat-actions." + id) + } + + private func requireEmptyProject(_ id: String) throws { + guard UUID(uuidString: id) != nil else { throw LibraryCleanupIssue.invalidJournal } + try MenuChatUI.expand(id, in: app, pageLimit: 20) + let probe = app.staticTexts["library.cleanup-project." + id] + guard probe.waitForExistence(timeout: 15) else { throw LibraryCleanupIssue.inaccessibleControl } + try cleanupWait { probe.label != "loading" || self.app.staticTexts["library.error"].exists } + guard probe.label != "error", !app.staticTexts["library.error"].exists else { + throw LibraryCleanupIssue.loadingFailed + } + // The raw owner includes pinned children and all page state. An empty + // filtered menu or collapsed folder alone is never deletion authority. + guard probe.label == "empty" else { throw LibraryCleanupIssue.unjournaledProjectChat } + } + + private func soleProjectChat(_ id: String) throws -> XCUIElement { + try MenuChatUI.expand(id, in: app, pageLimit: 20) + let probe = app.staticTexts["library.cleanup-project." + id] + try cleanupWait { probe.exists && probe.label != "loading" } + guard probe.label == "nonempty", !app.staticTexts["library.error"].exists else { + throw LibraryCleanupIssue.loadingFailed + } + let root = app.collectionViews["library.sidebar-list"] + let folder = root.buttons["library.project." + id] + guard folder.exists, folder.isHittable else { throw LibraryCleanupIssue.inaccessibleControl } + let followingBoundary = root.descendants(matching: .any).allElementsBoundByIndex.filter { item in + let projectID = String(item.identifier.dropFirst("library.project.".count)) + let isFolder = item.identifier.hasPrefix("library.project.") && UUID(uuidString: projectID) != nil + return item.exists + && (isFolder || item.identifier == "library.drop.pinned" + || item.identifier == "library.drop.recent") + && item.frame.minY >= folder.frame.maxY + }.map(\.frame.minY).min() + guard let followingBoundary else { throw LibraryCleanupIssue.inaccessibleControl } + let children = root.buttons.matching( + NSPredicate(format: "identifier BEGINSWITH %@", "library.chat.") + ).allElementsBoundByIndex.filter { + $0.exists && $0.frame.minY >= folder.frame.maxY && $0.frame.maxY <= followingBoundary + } + guard children.count == 1, let row = children.first, + UUID(uuidString: String(row.identifier.dropFirst("library.chat.".count))) != nil + else { throw LibraryCleanupIssue.unjournaledProjectChat } + return row + } + + func testLocalComposerProjectPickerCreatesSelectsClearsAndDeletesOwnedProject() throws { + try launchLocal() + let project = uniqueName("picker project") + tap("chat.new") + let composer = app.textViews["chat.composer"] + XCTAssertTrue(composer.waitForExistence(timeout: 15)) + let draft = try XCTUnwrap(composer.value as? String) + tap("chat.model"); tap("chat-options.project") + let picker = app.navigationBars["Projects"] + XCTAssertTrue(picker.waitForExistence(timeout: 15)) + tap("project-picker.new-project") + let field = app.textFields["project-editor.name"] + XCTAssertTrue(field.waitForExistence(timeout: 10)) + XCTAssertFalse(app.buttons["project-editor.save"].isEnabled) + field.tap(); field.typeText(project) + referenceScreenshot("composer project picker create") + tap("project-editor.save") + let returned = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + !picker.exists && !field.exists && self.app.buttons["chat-options.project"].isHittable + }, object: app) + let creationReturned = XCTWaiter.wait(for: [returned], timeout: 20) + // Record the returned UUID before asserting dismissal/selection, so an + // observed created project remains owned by the existing teardown. + if !picker.exists { tap("chat-options.project") } + let createdRows = app.buttons.matching( + NSPredicate(format: "identifier BEGINSWITH %@ AND label CONTAINS %@", "project-picker.project.", project)) + let created = createdRows.firstMatch + XCTAssertTrue(created.waitForExistence(timeout: 20)) + XCTAssertEqual(createdRows.count, 1, "The unique synthetic project must resolve to exactly one picker row") + let id = String(created.identifier.dropFirst("project-picker.project.".count)) + guard UUID(uuidString: id) != nil else { throw LibraryCleanupIssue.invalidJournal } + try rememberProject(id); currentProjectID = id + XCTAssertEqual(creationReturned, .completed, "Creating a project must return to Chat options") + XCTAssertTrue(created.isSelected, "The returned project must be selected in the composer") + referenceScreenshot("composer created project selected") + tap("project-picker.project.none") + try cleanupWait { !picker.exists && self.app.buttons["chat-options.project"].isHittable } + tap("chat-options.project") + let none = app.buttons["project-picker.project.none"] + XCTAssertTrue(none.waitForExistence(timeout: 15)) + XCTAssertTrue(none.isSelected, "No project selection must be retained after reopening") + XCTAssertFalse(created.isSelected) + referenceScreenshot("composer no project selected") + tap("project-picker.project.none") + try cleanupWait { !picker.exists && self.app.buttons["chat-options.project"].isHittable } + try dismissChatOptionsWithSelectedModel() + XCTAssertEqual(composer.value as? String, draft) + openMenu(); try requireEmptyProject(id); openProject(project); deleteCurrentProject() + XCTAssertFalse(app.buttons["library.project." + id].exists) + } + + func testLocalProjectInstructionsSaveDiscardRenameAndExactDeletion() throws { + try launchLocal() + let project = uniqueName("project"), renamed = project + " renamed" + openMenu() + referenceScreenshot("sidebar before creating the owned project") + try createProject(project); openProject(project) + tap("project-editor.instructions") + let editor = app.textViews["library.instructions-text"] + try replace(editor, with: "Use concise responses for this synthetic local project.") + referenceScreenshot("project instructions editor before Save") + tap("library.instructions-save") + XCTAssertTrue(app.navigationBars[project].waitForExistence(timeout: 20)) + tap("project-editor.instructions") + XCTAssertTrue(editor.waitForExistence(timeout: 15)) + XCTAssertEqual(editor.value as? String, "Use concise responses for this synthetic local project.") + try replace(editor, with: "This edit must be discarded.") + tap("library.instructions-cancel") + XCTAssertTrue(app.buttons["Discard changes"].waitForExistence(timeout: 10)) + referenceScreenshot("unsaved instructions discard warning") + tap("Discard changes") + tap("project-editor.instructions") + XCTAssertTrue(editor.waitForExistence(timeout: 15)) + XCTAssertEqual(editor.value as? String, "Use concise responses for this synthetic local project.") + tap("library.instructions-cancel") + tap("project-editor.rename") + try replace(app.textFields["project-editor.name"], with: renamed) + referenceScreenshot("rename-project form") + tap("project-editor.save") + XCTAssertTrue(app.navigationBars[renamed].waitForExistence(timeout: 20)) + let screenshot = XCTAttachment(screenshot: app.screenshot()) + screenshot.name = "Local native project and saved instructions"; screenshot.lifetime = .keepAlways; + add(screenshot) + deleteCurrentProject() + XCTAssertFalse(app.staticTexts[project].exists); XCTAssertFalse(app.staticTexts[renamed].exists) + } + + func testLocalProjectChatRenamePinMoveHistoryAndExactDeletion() throws { + try launchLocal() + let project = uniqueName("chat project"), chatName = uniqueName("chat") + openMenu(); try createProject(project) + guard let currentProjectID else { throw LibraryCleanupIssue.invalidJournal } + try requireEmptyProject(currentProjectID) + try rememberPendingCreation(currentProjectID) + tap("library.project.new-chat." + currentProjectID) + let composer = app.textViews["chat.composer"] + try replace(composer, with: "Reply with exactly: Native library workflow passed.") + tap("chat.send") + // Capture only a state actually observed during this existing request. + // Fast completion can omit this attachment; it is not a streaming pass. + if app.buttons["chat.stop"].exists && app.buttons["chat.stop"].isHittable { + referenceScreenshot("active local request with Stop visible") + } + let completed = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.app.descendants(matching: .any).matching( + NSPredicate(format: "identifier BEGINSWITH %@", "assistant.message.") + ).count > 0 + && !self.app.buttons["chat.stop"].exists + }, object: app) + XCTAssertEqual(XCTWaiter.wait(for: [completed], timeout: 90), .completed) + XCTAssertFalse(app.staticTexts["chat.error"].exists) + openMenu() + let initial = try soleProjectChat(currentProjectID) + let chatID = String(initial.identifier.dropFirst("library.chat.".count)) + try rememberChat(chatID); currentChatID = chatID + try openChatActions(chatID) + XCTAssertTrue(app.buttons["Rename"].waitForExistence(timeout: 10)) + referenceScreenshot("chat context menu") + tap("Rename") + try replace(app.textFields["library.rename-title"], with: chatName) + referenceScreenshot("rename-chat dialog") + tap("library.rename-save") + let chat = row(named: chatName) + XCTAssertTrue(chat.waitForExistence(timeout: 20)); try openChatActions(chatID); tap("Pin") + try openChatActions(chatID); XCTAssertTrue(app.buttons["Unpin"].waitForExistence(timeout: 15)) + referenceScreenshot("pinned chat context menu") + tap("Unpin") + try openChatActions(chatID); tap("Move to project") + XCTAssertTrue(app.buttons["No project"].waitForExistence(timeout: 10)) + referenceScreenshot("move-chat destination chooser") + tap("No project") + try requireEmptyProject(currentProjectID) + // The project now owns no chat, so its destructive cleanup is narrowly scoped. + openProject(project); deleteCurrentProject() + let search = app.textFields["library.search"] + XCTAssertTrue(search.waitForExistence(timeout: 15)); search.tap(); search.typeText(chatName) + XCTAssertTrue(chat.waitForExistence(timeout: 20)) + referenceScreenshot("filtered Recent menu with the exact owned chat") + try openChatActions(chatID); tap("Delete") + let confirm = app.buttons["Delete"].firstMatch + XCTAssertTrue(confirm.waitForExistence(timeout: 10)) + referenceScreenshot("chat deletion confirmation") + confirm.tap() + let deleted = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in !chat.exists }, object: app) + XCTAssertEqual(XCTWaiter.wait(for: [deleted], timeout: 30), .completed) + XCTAssertFalse(app.staticTexts["library.error"].exists) + if let currentChatID { try forgetChat(currentChatID) } + } +} + +private struct LibraryCleanupJournal: Codable { + var owner: String + var projectIDs: Set = [] + var chatIDs: Set = [] + var pendingCreationProjectIDs: Set = [] +} +private enum LibraryCleanupIssue: Error { + case noAccountMarker, invalidJournal, accountMismatch, inaccessibleControl, loadingFailed + case paginationLimit, unjournaledProjectChat, deletionUnconfirmed +} + +extension LibraryWorkflowUITests { + private var journalURL: URL { + get throws { + let root = try FileManager.default.url( + for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true + ).appendingPathComponent("NativeLibraryUITestCleanup", isDirectory: true) + try FileManager.default.createDirectory( + at: root, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + var excluded = URLResourceValues(); excluded.isExcludedFromBackup = true + var directory = root; try directory.setResourceValues(excluded) + return root.appendingPathComponent("exact-entities-v1.json") + } + } + private func accountMarker() throws -> String { + let marker = app.staticTexts["library.cleanup-account"] + guard marker.waitForExistence(timeout: 20), marker.label.count == 64, + marker.label.allSatisfy({ $0.isHexDigit }) + else { throw LibraryCleanupIssue.noAccountMarker } + return marker.label + } + private func readJournal() throws -> LibraryCleanupJournal? { + let url = try journalURL + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + let data = try Data(contentsOf: url) + guard data.count <= 64 * 1_024, + let value = try? JSONDecoder().decode(LibraryCleanupJournal.self, from: data), + value.owner.count == 64, value.owner.allSatisfy({ $0.isHexDigit }), + value.projectIDs.count <= 64, value.chatIDs.count <= 64, + value.pendingCreationProjectIDs.isSubset(of: value.projectIDs), + (value.projectIDs.union(value.chatIDs)).allSatisfy({ UUID(uuidString: $0) != nil }) + else { throw LibraryCleanupIssue.invalidJournal } + return value + } + private func writeJournal(_ journal: LibraryCleanupJournal) throws { + // Retain an empty journal too: an interrupted process can reuse the same + // file without broad filesystem cleanup or any account-owned app storage. + let data = try JSONEncoder().encode(journal) + try data.write(to: journalURL, options: [.atomic, .completeFileProtection]) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: journalURL.path) + } + private func ownedJournal() throws -> LibraryCleanupJournal { + guard let owner = observedCleanupOwner else { throw LibraryCleanupIssue.noAccountMarker } + guard let saved = try readJournal() else { return LibraryCleanupJournal(owner: owner) } + guard saved.owner == owner || (saved.projectIDs.isEmpty && saved.chatIDs.isEmpty) else { + throw LibraryCleanupIssue.accountMismatch + } + return saved.owner == owner ? saved : LibraryCleanupJournal(owner: owner) + } + private func rememberProject(_ id: String) throws { + guard UUID(uuidString: id) != nil else { throw LibraryCleanupIssue.invalidJournal } + var journal = try ownedJournal(); journal.projectIDs.insert(id) + recordCleanupIDs(journal); try writeJournal(journal) + } + private func rememberChat(_ id: String) throws { + guard UUID(uuidString: id) != nil else { throw LibraryCleanupIssue.invalidJournal } + var journal = try ownedJournal(); journal.chatIDs.insert(id) + if let currentProjectID { journal.pendingCreationProjectIDs.remove(currentProjectID) } + recordCleanupIDs(journal); try writeJournal(journal) + } + private func rememberPendingCreation(_ projectID: String) throws { + var journal = try ownedJournal() + guard journal.projectIDs.contains(projectID) else { throw LibraryCleanupIssue.invalidJournal } + journal.pendingCreationProjectIDs.insert(projectID); try writeJournal(journal) + } + private func forgetProject(_ id: String) throws { + var journal = try ownedJournal(); journal.projectIDs.remove(id) + journal.pendingCreationProjectIDs.remove(id); try writeJournal(journal) + } + private func forgetChat(_ id: String) throws { + var journal = try ownedJournal(); journal.chatIDs.remove(id); try writeJournal(journal) + } + private func recordCleanupIDs(_ journal: LibraryCleanupJournal) { + let value = + "Projects: " + journal.projectIDs.sorted().joined(separator: ",") + + "\nChats: " + journal.chatIDs.sorted().joined(separator: ",") + + "\nUnresolved creation in projects: " + journal.pendingCreationProjectIDs.sorted().joined(separator: ",") + let attachment = XCTAttachment(string: value) + attachment.name = "Exact owned local entities for cleanup"; attachment.lifetime = .keepAlways; add(attachment) + } + private func cleanupTap(_ identifier: String) throws { + let target = identifier == "chat.menu" ? app.mapleMenuButton : app.buttons[identifier].firstMatch + guard target.waitForExistence(timeout: 10) else { throw LibraryCleanupIssue.inaccessibleControl } + for _ in 0..<12 where !target.isHittable { app.swipeUp() } + guard target.isHittable else { throw LibraryCleanupIssue.inaccessibleControl } + target.tap() + } + private func cleanupWait(_ condition: @escaping @MainActor () -> Bool) throws { + let expectation = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in condition() }, object: app) + guard XCTWaiter.wait(for: [expectation], timeout: 15) == .completed else { + throw LibraryCleanupIssue.loadingFailed + } + } + private func cleanupRestart() throws { + app.terminate(); app.launchArguments = ["--maple-library-local-cleanup"]; app.launch() + guard app.buttons["chat.new"].waitForExistence(timeout: 30) else { + throw LibraryCleanupIssue.inaccessibleControl + } + observedCleanupOwner = try accountMarker() + _ = try ownedJournal() + try cleanupTap("chat.menu") + guard app.textFields["library.search"].waitForExistence(timeout: 15) else { + throw LibraryCleanupIssue.inaccessibleControl + } + } + private func cleanupChat(_ id: String) throws { + try cleanupRestart() + let projects = try ownedJournal().projectIDs.sorted() + guard let row = try MenuChatUI.find(id, in: app, projectIDs: projects, pageLimit: 20) else { + throw LibraryCleanupIssue.deletionUnconfirmed + } + XCTAssertEqual(row.identifier, "library.chat." + id) + guard row.isHittable else { throw LibraryCleanupIssue.inaccessibleControl } + try cleanupTap("library.chat-actions." + id); try cleanupTap("Delete") + guard app.staticTexts["Are you sure you want to delete this chat?"].waitForExistence(timeout: 10) else { + throw LibraryCleanupIssue.inaccessibleControl + } + try cleanupTap("Delete") + try cleanupWait { !row.exists || self.app.staticTexts["library.error"].exists } + guard !row.exists, !app.staticTexts["library.error"].exists else { + throw LibraryCleanupIssue.deletionUnconfirmed + } + try forgetChat(id) + } + private func cleanupProject(_ id: String) throws { + try cleanupRestart() + guard try !ownedJournal().pendingCreationProjectIDs.contains(id) else { + throw LibraryCleanupIssue.unjournaledProjectChat + } + let target = app.buttons.matching(identifier: "library.project." + id).firstMatch + // Missing or inaccessible IDs remain journaled. Only a confirmed UI + // deletion removes an ID; a page load is not authority to forget it. + try cleanupWait { target.exists || self.app.staticTexts["library.error"].exists } + if app.staticTexts["library.error"].exists { throw LibraryCleanupIssue.loadingFailed } + guard target.exists else { throw LibraryCleanupIssue.deletionUnconfirmed } + for _ in 0..<20 where !target.isHittable { app.swipeUp() } + guard target.isHittable else { throw LibraryCleanupIssue.inaccessibleControl } + // Every recorded chat is cleaned first. Refuse cascade deletion if the + // project now contains any other chat, including an unrecorded test result. + try requireEmptyProject(id) + target.press(forDuration: 1); try cleanupTap("library.project.manage." + id) + try cleanupTap("project-editor.delete") + let field = app.textFields["library.delete-confirmation"] + guard field.waitForExistence(timeout: 10) else { throw LibraryCleanupIssue.inaccessibleControl } + field.tap(); field.typeText("DELETE"); try cleanupTap("library.delete-confirm") + try cleanupWait { self.app.buttons["library.new-project"].isHittable && !target.exists } + guard !app.staticTexts["library.error"].exists, !target.exists else { + throw LibraryCleanupIssue.deletionUnconfirmed + } + try forgetProject(id) + } + private func cleanupJournaledEntities() throws { + guard ProcessInfo.processInfo.environment["MAPLE_RUN_LOCAL_UI"] == "1", let app else { return } + guard let journal = try readJournal(), !journal.chatIDs.isEmpty || !journal.projectIDs.isEmpty else { return } + try cleanupRestart() + recordCleanupIDs(journal) + for id in journal.chatIDs.sorted() { try cleanupChat(id) } + for id in journal.projectIDs.sorted() { try cleanupProject(id) } + // Restore the normal root before another test begins creating resources. + app.terminate(); app.launchArguments = ["--maple-library-local-cleanup"]; app.launch() + guard app.buttons["chat.new"].waitForExistence(timeout: 30) else { + throw LibraryCleanupIssue.inaccessibleControl + } + } +} diff --git a/apps/maple-ios/UITests/LocalizationUITests.swift b/apps/maple-ios/UITests/LocalizationUITests.swift new file mode 100644 index 000000000..a95ac760b --- /dev/null +++ b/apps/maple-ios/UITests/LocalizationUITests.swift @@ -0,0 +1,881 @@ +import UIKit +import XCTest + +/// Run against the dedicated signed-in local fixture on iPhone and a regular-width +/// iPad window. No chat is sent or server record is created. The text is synthetic. +@MainActor final class LocalizationUITests: XCTestCase { + private enum Mode { case expanded, rightToLeft } + private let sample = "العربية שלום — 123\n日本語 中文 한글\n👩🏽‍💻 👨‍👩‍👧‍👦 🇨🇦 e\u{301} A\u{30A}\nlogical end 456" + + /// A separate read-only system diagnostic, not a localization/reference pass. + /// Uses one native touch to reveal Control Center; it never toggles a control. + @MainActor func testReadOnlyIPadControlCenterRotationState() throws { + continueAfterFailure = false + try XCTSkipUnless(ProcessInfo.processInfo.environment["MAPLE_RUN_LOCAL_UI"] == "1") + try XCTSkipUnless(UIDevice.current.userInterfaceIdiom == .pad) + let app = XCUIApplication() + app.activate() + let restore: @MainActor @Sendable () throws -> Void = { [weak self] in + XCUIDevice.shared.press(.home) + app.activate() + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10)) + try self?.captureRotationObservation(app, name: "Maple after system observation") + } + addTeardownBlock { try await restore() } + XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 10)) + try captureRotationObservation(app, name: "Maple before system observation") + XCUIDevice.shared.press(.home) + let system = XCUIApplication(bundleIdentifier: "com.apple.springboard") + XCTAssertTrue(system.wait(for: .runningForeground, timeout: 10)) + let frame = system.frame + try captureRotationObservation(system, name: "Home before native edge gesture") + XCTAssertEqual(frame.minX, 0, accuracy: 2) + XCTAssertEqual(frame.minY, 0, accuracy: 2) + XCTAssertEqual(frame.width, 834, accuracy: 2) + XCTAssertEqual(frame.height, 1210, accuracy: 2) + // Coordinates belong to the observed full-screen system owner, not an + // inset application window. XCUICoordinate.press synthesizes touch. + let origin = system.coordinate(withNormalizedOffset: .zero) + let start = origin.withOffset(CGVector(dx: frame.width - 12, dy: 2)) + let end = origin.withOffset(CGVector(dx: frame.width - 12, dy: frame.height * 0.35)) + start.press(forDuration: 0.05, thenDragTo: end) + let locks = system.buttons.matching( + NSPredicate( + format: "label CONTAINS[c] %@ AND (label CONTAINS[c] %@ OR label CONTAINS[c] %@)", + "lock", "rotation", "orientation")) + _ = locks.firstMatch.waitForExistence(timeout: 10) + let candidates = locks.allElementsBoundByIndex + // Never dump the system hierarchy, labels, media titles or arbitrary + // values. Selection alone is not a documented rotation-lock accessor. + let controls: [[String: Any]] = candidates.prefix(4).map { control in + let rawValue = (control.value as? String)?.lowercased() ?? "" + let value = + ["0", "1", "on", "off", "locked", "unlocked"].contains(rawValue) + ? rawValue : "unavailable-or-unrecognized" + return [ + "frame": [control.frame.minX, control.frame.minY, control.frame.width, control.frame.height], + "value": value, "selected": control.isSelected, "hittable": control.isHittable, + ] + } + try captureRotationObservation( + system, name: "Control Center rotation-lock observation", + extra: [ + "matching_control_count": candidates.count, "controls": controls, + "setting_mutated": false, "interpretation": "Observed AX only; unknown does not mean unlocked", + ]) + XCTAssertEqual(candidates.count, 1, "Require one actual rotation-lock control; never infer missing means OFF") + XCTAssertTrue(candidates.first?.isHittable == true) + } + + private func captureRotationObservation( + _ owner: XCUIApplication, name: String, extra: [String: Any] = [:] + ) throws { + let screenshot = XCUIScreen.main.screenshot() + let image = screenshot.image + var record = extra + record["physical_orientation"] = XCUIDevice.shared.orientation.rawValue + record["application_frame"] = [owner.frame.minX, owner.frame.minY, owner.frame.width, owner.frame.height] + record["window_frames"] = owner.windows.allElementsBoundByIndex.prefix(4).map { + [$0.frame.minX, $0.frame.minY, $0.frame.width, $0.frame.height] + } + record["screen_pixels"] = [image.cgImage?.width ?? 0, image.cgImage?.height ?? 0] + let metadata = XCTAttachment( + data: try JSONSerialization.data(withJSONObject: record, options: .sortedKeys), + uniformTypeIdentifier: "public.json") + metadata.name = name + " geometry"; metadata.lifetime = .keepAlways; add(metadata) + let capture = XCTAttachment(screenshot: screenshot) + capture.name = name; capture.lifetime = .keepAlways; add(capture) + } + + @MainActor func testDoubleLengthLargeTextPreservesShippingNavigationAndLogicalDraft() throws { + try run(.expanded) + } + + @MainActor func testRightToLeftLargeTextMirrorsShippingControlsAndPreservesLogicalDraft() throws { + try run(.rightToLeft) + } + + /// An additional focused iPad proof, separate from the two full localization + /// walkthroughs. It restores the observed OS window and never sends a chat. + @MainActor func testActualIPadWindowResizeKeepsLargeRTLLibraryNavigationReachable() throws { + continueAfterFailure = false + try XCTSkipUnless(ProcessInfo.processInfo.environment["MAPLE_RUN_LOCAL_UI"] == "1") + try XCTSkipUnless(UIDevice.current.userInterfaceIdiom == .pad) + let app = XCUIApplication() + app.launchArguments = arguments(mode: .rightToLeft) + ["--maple-library-local-cleanup"] + app.launch() + try waitForRoot(app) + try assertObservedTraits(app, rtl: true) + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 10)) + let original = window.frame + try XCTSkipUnless( + abs(original.minX) < 2 && abs(original.minY) < 2 + && abs(original.width - 834) < 2 && abs(original.height - 1210) < 2, + "Requires the observed full portrait iPad window and its visible system resize handle") + let restore: @MainActor @Sendable () -> Void = { [weak self] in + guard let self else { return } + guard window.exists else { + XCTFail("The owned window must remain available for restoration") + return + } + if self.libraryWindowFrame(window.frame, matches: original) { return } + self.captureLibraryWindow(app, name: "Window before teardown restoration", frame: window.frame) + self.resizeLibraryWindow( + in: app, from: window.frame, + to: CGPoint(x: original.maxX - 10, y: original.maxY - 15)) + XCTAssertTrue(self.waitForLibraryWindow(window, matching: original), "Restore the original iPad window") + self.captureLibraryWindow(app, name: "Window after teardown restoration", frame: window.frame) + } + // XCTest executes registered teardown after a fail-fast assertion. Swift + // defer alone did not run when XCTest ended the previous failing method. + addTeardownBlock { await restore() } + let fingerprint = try accountFingerprint(in: app) + let composer = app.textViews["chat.composer"] + let originalDraft = try XCTUnwrap(composer.value as? String) + try openLibrary(app) + try assertLibrarySearchAndMenu(in: app, window: original) + captureLibraryWindow(app, name: "RTL large-text Library before system resize", frame: original) + + // These are the insets of the observed public bottom-right handle. Use + // this window's fresh screen frame for each gesture, including restoration. + resizeLibraryWindow( + in: app, from: window.frame, + to: CGPoint(x: original.minX + original.width * 0.72, y: original.minY + original.height * 0.76)) + let smaller = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + let current = window.frame + return current.width < original.width - 80 && current.height < original.height - 80 + }, object: window) + guard XCTWaiter.wait(for: [smaller], timeout: 15) == .completed else { + XCTFail("Only an observed OS window-frame reduction establishes the resize") + throw NativeTextEntryIssue.unavailable + } + let compact = window.frame + XCTAssertGreaterThan(compact.width, 300) + XCTAssertGreaterThan(compact.height, 500) + try openLibrary(app) + try assertLibrarySearchAndMenu(in: app, window: compact) + try assertPhysicalLeftDrawer(in: app) + let close = app.buttons["library.close"] + XCTAssertTrue(close.waitForExistence(timeout: 10), "The compact menu must provide its app-owned Close") + try assertTarget(close, in: app) + XCTAssertTrue(compact.insetBy(dx: -0.5, dy: -0.5).contains(close.frame)) + captureLibraryWindow(app, name: "RTL large-text Library in actual smaller window", frame: compact) + try closeLibrary(app) + XCTAssertTrue(composer.value as? String == originalDraft, "Navigation must retain the same draft") + XCTAssertTrue(try accountFingerprint(in: app) == fingerprint, "Resize must retain the same account") + + resizeLibraryWindow( + in: app, from: window.frame, + to: CGPoint(x: original.maxX - 10, y: original.maxY - 15)) + guard waitForLibraryWindow(window, matching: original) else { + XCTFail("Restore the original iPad window") + throw NativeTextEntryIssue.unavailable + } + try assertObservedTraits(app, rtl: true) + try openLibrary(app) + try assertLibrarySearchAndMenu(in: app, window: window.frame) + captureLibraryWindow(app, name: "RTL large-text Library after window restoration", frame: window.frame) + app.buttons["library.actions"].tap() + try assertNativeMenuCommand(app.buttons["library.settings"], in: app) + app.buttons["library.settings"].tap() + _ = try settingsCollection(app) + let settingsClose = app.buttons["library.settings-close"] + XCTAssertTrue(settingsClose.waitForExistence(timeout: 10), "Regular-width Settings must have app-owned Close") + try assertTarget(settingsClose, in: app) + try closeSettingsAndLibrary(app) + XCTAssertTrue(composer.value as? String == originalDraft, "The same draft must survive restored navigation") + XCTAssertTrue(try accountFingerprint(in: app) == fingerprint, "Navigation must retain the same account") + } + + @MainActor private func accountFingerprint(in app: XCUIApplication) throws -> String { + // Existing DEBUG/local diagnostic exposes only a digest. Compare it in + // memory; never attach or print an account identifier or draft contents. + let marker = app.staticTexts["library.cleanup-account"] + XCTAssertTrue(marker.waitForExistence(timeout: 10)) + let value = marker.label + guard value.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil else { + throw NativeTextEntryIssue.unavailable + } + return value + } + + @MainActor private func assertLibrarySearchAndMenu( + in app: XCUIApplication, window: CGRect + ) throws { + let search = app.textFields["library.search"], actions = app.buttons["library.actions"] + // These persistent controls must be reachable without scrolling loaded chats. + for control in [search, actions] { + try assertTarget(control, in: app) + XCTAssertTrue(window.insetBy(dx: -0.5, dy: -0.5).contains(control.frame)) + } + XCTAssertTrue(search.frame.intersection(actions.frame).isEmpty, "Search and Maple menu must not overlap") + XCTAssertGreaterThanOrEqual(search.frame.minY, actions.frame.maxY, "Search floats below the Maple menu") + XCTAssertLessThanOrEqual(search.frame.height + actions.frame.height, min(240, window.height * 0.3) + 1) + let list = try XCTUnwrap(visibleCollection(in: app, prefix: "library.")) + let scrolling = visibleRegion(list, in: app) + let contentTop = max(scrolling.minY, actions.frame.maxY) + let contentBottom = min(scrolling.maxY, search.frame.minY) + XCTAssertGreaterThanOrEqual( + contentBottom - contentTop, 100, "Floating search and Maple menu must leave usable scrolling content") + XCTAssertFalse(app.buttons["library.history"].exists) + } + + @MainActor private func resizeLibraryWindow(in app: XCUIApplication, from frame: CGRect, to point: CGPoint) { + let window = app.windows.firstMatch + let origin = window.coordinate(withNormalizedOffset: .zero) + let windowOrigin = window.frame.origin + let start = origin.withOffset( + CGVector(dx: frame.maxX - windowOrigin.x - 10, dy: frame.maxY - windowOrigin.y - 15)) + let end = origin.withOffset(CGVector(dx: point.x - windowOrigin.x, dy: point.y - windowOrigin.y)) + start.press(forDuration: 0.2, thenDragTo: end) + } + + @MainActor private func waitForLibraryWindow(_ window: XCUIElement, matching expected: CGRect) -> Bool { + let matching = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.libraryWindowFrame(window.frame, matches: expected) + }, object: window) + return XCTWaiter.wait(for: [matching], timeout: 15) == .completed + } + + private func libraryWindowFrame(_ frame: CGRect, matches expected: CGRect) -> Bool { + abs(frame.minX - expected.minX) < 2 && abs(frame.minY - expected.minY) < 2 + && abs(frame.width - expected.width) < 2 && abs(frame.height - expected.height) < 2 + } + + // The application snapshot uses a local (0, 0) origin for a resized iPad, + // while window and descendant frames are screen-relative. The app has one + // window; use that actual window consistently for containment and coordinates. + private func windowFrame(in app: XCUIApplication) -> CGRect { app.windows.firstMatch.frame } + + private func recordTargetGeometry(_ element: XCUIElement, in app: XCUIApplication) { + let geometry = XCTAttachment( + string: + "Application: \(app.frame); window: \(windowFrame(in: app)); target: \(element.frame)") + // The identifier is app-owned; attach no label, value, account or draft. + geometry.name = "Target geometry — " + element.identifier + geometry.lifetime = .keepAlways; add(geometry) + } + + @MainActor private func captureLibraryWindow(_ app: XCUIApplication, name: String, frame: CGRect) { + let image = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + image.name = name; image.lifetime = .keepAlways; add(image) + let geometry = XCTAttachment( + string: + "Window frame points: x=\(frame.minX), y=\(frame.minY), width=\(frame.width), height=\(frame.height)") + geometry.name = name + " — measured frame"; geometry.lifetime = .keepAlways; add(geometry) + } + + @MainActor private func run(_ mode: Mode) throws { + continueAfterFailure = false + try XCTSkipUnless(ProcessInfo.processInfo.environment["MAPLE_RUN_LOCAL_UI"] == "1") + let app = XCUIApplication() + defer { UIPasteboard.general.items = [] } + app.launchArguments = arguments() + app.launch() + try waitForRoot(app) + let ltrMenu = app.mapleMenuButton.frame + let ltrNew = app.buttons["chat.new"].frame + XCTAssertLessThan(ltrMenu.midX, ltrNew.midX, "Baseline toolbar must actually be left-to-right") + let baselineLibrary = try openLibrary(app) + let libraryNew = app.buttons["library.new-chat"] + try reveal(libraryNew, in: app, owner: baselineLibrary, down: true, attempts: 24) + XCTAssertTrue(libraryNew.waitForExistence(timeout: 10)) + let baseNewLabel = libraryNew.label + try assertTarget(app.buttons["library.actions"], in: app) + app.buttons["library.actions"].tap() + try assertNativeMenuCommand(app.buttons["library.settings"], in: app) + let baseSettingsLabel = app.buttons["library.settings"].label + app.buttons["library.settings"].tap() + _ = try settingsCollection(app) + app.terminate() + + app.launchArguments = arguments(mode: mode) + app.launch() + try waitForRoot(app) + try assertObservedTraits(app, rtl: mode == .rightToLeft) + let menu = app.mapleMenuButton, newChat = app.buttons["chat.new"] + try assertTarget(menu, in: app) + try assertTarget(newChat, in: app) + if mode == .rightToLeft { + XCTAssertGreaterThan(menu.frame.midX, newChat.frame.midX, "RTL must mirror actual toolbar geometry") + } else { + XCTAssertLessThan(menu.frame.midX, newChat.frame.midX) + } + newChat.tap() + let composer = app.textViews["chat.composer"] + XCTAssertTrue(composer.waitForExistence(timeout: 15)) + // Native paste preserves scalar order, combining marks and multiline text; + // synthetic input never crosses a send or backend mutation boundary. + try pasteSynthetic(sample, into: composer, in: app) + try assertLogicalSelectionCopy(sample, from: composer, in: app) + try assertComposerViewport(composer, in: app) + capture(app, name: "Localization multiline CJK RTL emoji fallback") + XCTAssertEqual(composer.value as? String, sample) + try assertTarget(app.buttons["chat.attach"], in: app) + try assertTarget(app.buttons["chat.model"], in: app) + try assertTarget(app.buttons["chat.send"], in: app) + XCTAssertFalse(app.buttons["chat.stop"].exists) + // Navigation commits the protected draft before the later process restart. + var activeLibrary = try openLibrary(app) + let compactMenu = app.buttons["library.close"].exists + if compactMenu { + try assertPhysicalLeftDrawer(in: app) + try closeLibrary(app) + // Placement and opening remain physical-left even while all content + // and native toolbar controls follow the user's RTL direction. + let window = app.windows.firstMatch, frame = windowFrame(in: app) + let origin = window.coordinate(withNormalizedOffset: .zero) + let start = origin.withOffset(CGVector(dx: 4, dy: frame.height * 0.45)) + let end = origin.withOffset(CGVector(dx: min(190, frame.width * 0.55), dy: frame.height * 0.45)) + start.press(forDuration: 0.1, thenDragTo: end) + XCTAssertTrue(app.buttons["library.close"].waitForExistence(timeout: 15)) + try assertPhysicalLeftDrawer(in: app) + activeLibrary = try XCTUnwrap(visibleCollection(in: app, prefix: "library.")) + } + // Bottom search and the top Maple menu remain reachable at the largest + // text size in both directions without scrolling chats. + try assertLibrarySearchAndMenu(in: app, window: windowFrame(in: app)) + try reveal(libraryNew, in: app, owner: activeLibrary, down: true, attempts: 24) + XCTAssertTrue(libraryNew.waitForExistence(timeout: 10)) + if mode == .expanded { assertDoubled(libraryNew.label, baseline: baseNewLabel) } + try assertTarget(libraryNew, in: app) + capture(app, name: "Localization expanded or RTL shipping Library") + app.buttons["library.actions"].tap() + let settings = app.buttons["library.settings"] + try assertNativeMenuCommand(settings, in: app) + if mode == .expanded { assertDoubled(settings.label, baseline: baseSettingsLabel) } + try assertNativeMenuCommand(settings, in: app) + settings.tap() + let settingsOwner = try settingsCollection(app) + if compactMenu { + let drawer = app.otherElements["library.drawer"], bounds = windowFrame(in: app) + XCTAssertTrue(drawer.exists) + recordTargetGeometry(drawer, in: app) + XCTAssertEqual(app.descendants(matching: .any).matching(identifier: "library.drawer").count, 1) + XCTAssertEqual(app.otherElements.matching(identifier: "library.drawer").count, 1) + XCTAssertEqual(drawer.elementType, .other) + XCTAssertEqual(drawer.frame.width, bounds.width, accuracy: 1) + XCTAssertEqual(drawer.frame.height, bounds.height, accuracy: 1) + XCTAssertEqual(drawer.frame.minX, bounds.minX, accuracy: 1) + XCTAssertEqual(drawer.frame.maxX, bounds.maxX, accuracy: 1) + XCTAssertEqual(drawer.frame.minY, bounds.minY, accuracy: 1) + XCTAssertEqual(drawer.frame.maxY, bounds.maxY, accuracy: 1) + XCTAssertFalse(app.alerts["library.drawer"].exists) + XCTAssertFalse(app.sheets["library.drawer"].exists) + XCTAssertFalse(app.buttons["library.scrim"].exists) + } + let theme = app.buttons["settings.theme"] + try reveal(theme, in: app, owner: settingsOwner) + try assertTarget(theme, in: app) + let font = app.buttons["settings.font"] + try reveal(font, in: app, owner: settingsOwner) + try assertTarget(font, in: app) + let signOut = app.buttons["settings.sign-out"] + try reveal(signOut, in: app, owner: settingsOwner) + try assertTarget(signOut, in: app) + capture(app, name: "Localization shipping Settings large text") + // Observe destructive/account actions without activating them. + try closeSettingsAndLibrary(app) + XCTAssertTrue(composer.waitForExistence(timeout: 10)) + XCTAssertEqual(composer.value as? String, sample) + app.terminate(); app.launch() + try waitForRoot(app) + try assertObservedTraits(app, rtl: mode == .rightToLeft) + XCTAssertEqual(composer.value as? String, sample, "Cold launch must retain exact logical Unicode draft") + try assertLogicalSelectionCopy(sample, from: composer, in: app) + try assertComposerViewport(composer, in: app) + capture(app, name: "Localization cold restored multilingual draft") + // Finish with an empty synthetic New Chat draft; no remote cleanup needed. + try clearSynthetic(composer, in: app) + try openLibrary(app) + try closeLibrary(app) + } + + private func arguments(mode: Mode? = nil) -> [String] { + var result = [ + "--maple-localization-proof", "-AppleLanguages", "(en)", "-AppleLocale", "en_US", + "-NSDoubleLocalizedStrings", mode == .expanded ? "YES" : "NO", + "-AppleTextDirection", mode == .rightToLeft ? "YES" : "NO", + "-NSForceRightToLeftWritingDirection", mode == .rightToLeft ? "YES" : "NO", + ] + if mode != nil { + result += [ + "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL", + "-UIAccessibilityBoldTextEnabled", "YES", + ] + } + return result + } + + @MainActor private func waitForRoot(_ app: XCUIApplication) throws { + guard app.buttons["chat.new"].waitForExistence(timeout: 40), + app.textViews["chat.composer"].waitForExistence(timeout: 15) + else { + XCTFail("Dedicated local signed-in application did not reach the shipping chat") + throw NativeTextEntryIssue.unavailable + } + } + + @MainActor private func assertObservedTraits(_ app: XCUIApplication, rtl: Bool) throws { + let element = app.staticTexts["localization.observed-traits"] + XCTAssertTrue(element.waitForExistence(timeout: 10)) + let raw = try XCTUnwrap(element.value as? String) + let traits = try JSONDecoder().decode([String: Bool].self, from: Data(raw.utf8)) + XCTAssertEqual(traits["swiftAccessibility"], true, "Actual SwiftUI Dynamic Type must be accessibility size") + XCTAssertEqual(traits["uiAccessibility"], true, "Actual UIKit trait must be accessibility size") + XCTAssertTrue(traits["systemBold"] == true || traits["uiBold"] == true, "Bold Text must actually be enabled") + XCTAssertEqual(traits["swiftBold"], true, "Shipping SwiftUI must inherit bold legibility") + XCTAssertEqual(traits["swiftRTL"], rtl) + XCTAssertEqual(traits["uiRTL"], rtl) + } + + @MainActor private func visibleRegion(_ collection: XCUIElement, in app: XCUIApplication) -> CGRect { + guard collection.exists else { return .null } + return collection.frame.intersection(windowFrame(in: app)) + } + + @MainActor private func hasVisibleContent( + _ collection: XCUIElement, in app: XCUIApplication, prefix: String? = nil + ) -> Bool { + let region = visibleRegion(collection, in: app) + guard region.width >= 100, region.height >= 100 else { return false } + let descendants = collection.descendants(matching: .any) + let candidates = + prefix.map { + descendants.matching(NSPredicate(format: "identifier BEGINSWITH %@", $0)) + } ?? descendants + return candidates.allElementsBoundByIndex.contains { + $0.exists && $0.isHittable && !$0.frame.intersection(region).isEmpty + } + } + + @MainActor private func visibleCollection(in app: XCUIApplication, prefix: String) -> XCUIElement? { + app.collectionViews.allElementsBoundByIndex.first { + hasVisibleContent($0, in: app, prefix: prefix) + } + } + + @MainActor private func keyboardIsVisible(in app: XCUIApplication) -> Bool { + let keys = app.keyboards.buttons.allElementsBoundByIndex + guard keys.count <= 160 else { return true } + let visibleKey = keys.contains { + $0.exists && $0.isHittable && !$0.frame.intersection(windowFrame(in: app)).isEmpty + } + let hide = app.buttons.matching(NSPredicate(format: "label CONTAINS %@", "Hide keyboard")) + .allElementsBoundByIndex.first { + hasSystemTitle($0.label, title: "Hide keyboard") && $0.exists && $0.isHittable + } + return visibleKey || hide != nil + } + + @MainActor private func dismissTabletKeyboard(in app: XCUIApplication) throws { + guard UIDevice.current.userInterfaceIdiom == .pad, keyboardIsVisible(in: app) else { return } + // Observed public iPad control. The keyboard AX container can survive + // dismissal, so require no visible keys rather than container absence. + let candidates = app.buttons.matching(NSPredicate(format: "label CONTAINS %@", "Hide keyboard")) + _ = candidates.firstMatch.waitForExistence(timeout: 5) + guard + let hide = candidates.allElementsBoundByIndex.first(where: { + hasSystemTitle($0.label, title: "Hide keyboard") && $0.isHittable + }) + else { throw NativeTextEntryIssue.unavailable } + hide.tap() + let hidden = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in !self.keyboardIsVisible(in: app) }, object: app) + guard XCTWaiter.wait(for: [hidden], timeout: 10) == .completed else { + throw NativeTextEntryIssue.unavailable + } + } + + @MainActor @discardableResult private func openLibrary(_ app: XCUIApplication) throws -> XCUIElement { + try dismissTabletKeyboard(in: app) + if let owner = visibleCollection(in: app, prefix: "library.") { return owner } + app.mapleMenuButton.tap() + let visible = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in self.visibleCollection(in: app, prefix: "library.") != nil }, + object: app) + guard XCTWaiter.wait(for: [visible], timeout: 15) == .completed, + let owner = visibleCollection(in: app, prefix: "library.") + else { throw NativeTextEntryIssue.unavailable } + return owner + } + + @MainActor private func assertPhysicalLeftDrawer(in app: XCUIApplication) throws { + let candidates = app.descendants(matching: .any).matching(identifier: "library.drawer") + let drawer = candidates.firstMatch, window = app.windows.firstMatch + let positioned = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + guard drawer.exists, window.exists else { return false } + let frame = drawer.frame, bounds = self.windowFrame(in: app) + guard + [ + frame.minX, frame.minY, frame.width, frame.height, + bounds.minX, bounds.minY, bounds.width, bounds.height, + ].allSatisfy({ $0.isFinite }), + frame.width > 200, frame.height > 0, bounds.width > 0, bounds.height > 0 + else { return false } + return abs(frame.minX - bounds.minX) <= 1 && abs(frame.maxX - bounds.maxX) <= 1 + && abs(frame.minY - bounds.minY) <= 1 && abs(frame.maxY - bounds.maxY) <= 1 + && abs(frame.width - bounds.width) <= 1 && abs(frame.height - bounds.height) <= 1 + }, object: drawer) + let result = XCTWaiter.wait(for: [positioned], timeout: 10) + let count = candidates.count, typed = app.otherElements.matching(identifier: "library.drawer") + var geometry: [String: Double] = [ + "waitCompleted": result == .completed ? 1 : 0, + "candidateCount": Double(min(count, 9)), "candidateLimitExceeded": count > 8 ? 1 : 0, + "typedOtherCount": Double(min(typed.count, 9)), + ] + for (name, element) in [ + ("typedOther", typed.firstMatch), ("drawer", drawer), ("window", window), + ("scrim", app.buttons["library.scrim"]), + ("alertDrawer", app.alerts["library.drawer"]), ("sheetDrawer", app.sheets["library.drawer"]), + ("close", app.buttons["library.close"]), + ] { + let exists = element.exists, frame = exists ? element.frame : .zero + geometry[name + "Exists"] = exists ? 1 : 0 + geometry[name + "Hittable"] = exists && element.isHittable ? 1 : 0 + geometry[name + "Type"] = exists ? Double(element.elementType.rawValue) : 0 + geometry[name + "X"] = Double(frame.minX); geometry[name + "Y"] = Double(frame.minY) + geometry[name + "Width"] = Double(frame.width); geometry[name + "Height"] = Double(frame.height) + } + geometry = geometry.mapValues { $0.isFinite ? $0 : -Double.greatestFiniteMagnitude } + let data = try JSONSerialization.data(withJSONObject: geometry, options: .sortedKeys) + let record = XCTAttachment(string: String(decoding: data, as: UTF8.self)) + record.name = "Physical-left drawer geometry"; record.lifetime = .keepAlways; add(record) + XCTAssertLessThanOrEqual(count, 8) + XCTAssertEqual(count, 1, "The full-screen menu must have one accessibility owner") + XCTAssertEqual(typed.count, 1) + XCTAssertEqual(drawer.elementType, .other, "The menu must remain scene navigation") + XCTAssertEqual(result, .completed) + XCTAssertFalse(app.alerts["library.drawer"].exists) + XCTAssertFalse(app.sheets["library.drawer"].exists) + XCTAssertFalse(app.buttons["library.scrim"].exists, "The menu fills the screen without an exposed chat strip") + let close = app.buttons["library.close"] + try assertTarget(close, in: app) + let composer = app.textViews["chat.composer"] + XCTAssertFalse(composer.exists && composer.isHittable, "The offscreen chat must not accept input") + } + + @MainActor private func settingsCollection(_ app: XCUIApplication) throws -> XCUIElement { + // Shipping SettingsView names its Form. Use that owner even when only + // the account section is visible at large text, never the covered sidebar. + let owner = app.descendants(matching: .any).matching(identifier: "settings.screen").firstMatch + let visible = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in self.hasVisibleContent(owner, in: app) }, object: app) + guard XCTWaiter.wait(for: [visible], timeout: 15) == .completed else { + throw NativeTextEntryIssue.unavailable + } + return owner + } + + @MainActor private func closeLibrary(_ app: XCUIApplication) throws { + let close = app.buttons["library.close"] + if close.exists { + try assertTarget(close, in: app) + close.tap() + } else { + app.mapleMenuButton.tap() + } + XCTAssertTrue(app.textViews["chat.composer"].waitForExistence(timeout: 10)) + } + + @MainActor private func closeSettingsAndLibrary(_ app: XCUIApplication) throws { + let close = app.buttons["library.settings-close"] + if close.exists { + try assertTarget(close, in: app) + close.tap() + } else { + let back = app.navigationBars.buttons.element(boundBy: 0) + try assertTarget(back, in: app) + back.tap() + } + try closeLibrary(app) + } + + @MainActor private func reveal( + _ element: XCUIElement, in app: XCUIApplication, owner: XCUIElement, + down: Bool = false, attempts: Int = 14 + ) throws { + // Keep one actual list owner for the full reveal. A visible iPad sidebar + // container may report isHittable=false; app-wide swipes hit the chat. + for _ in 0..= 44, region.height >= 100 else { throw NativeTextEntryIssue.unavailable } + let top = CGPoint(x: region.midX, y: region.minY + region.height * 0.15) + let bottom = CGPoint(x: region.midX, y: region.maxY - region.height * 0.15) + let window = app.windows.firstMatch + let origin = window.coordinate(withNormalizedOffset: .zero) + let windowOrigin = window.frame.origin + func coordinate(_ point: CGPoint) -> XCUICoordinate { + origin.withOffset(CGVector(dx: point.x - windowOrigin.x, dy: point.y - windowOrigin.y)) + } + coordinate(down ? top : bottom).press(forDuration: 0.05, thenDragTo: coordinate(down ? bottom : top)) + } + // Record only finite rectangles; omitted nodes are absent or nonfinite. + // Labels, identifiers, values and conversation contents are not attached. + var rectangles: [String: [Double]] = [:] + let window = app.windows.firstMatch + for (name, frame) in [ + ("target", element.exists ? element.frame : CGRect.null), + ("owner", owner.exists ? owner.frame : CGRect.null), + ("window", window.exists ? window.frame : CGRect.null), + ] { + let values = [Double(frame.minX), Double(frame.minY), Double(frame.width), Double(frame.height)] + if values.allSatisfy(\.isFinite) { rectangles[name] = values } + } + if let data = try? JSONSerialization.data(withJSONObject: rectangles, options: .sortedKeys) { + let geometry = XCTAttachment(data: data, uniformTypeIdentifier: "public.json") + geometry.name = "Localized control reveal geometry" + geometry.lifetime = .keepAlways; add(geometry) + } + XCTFail("Shipping localized control could not be reached in its owning list") + throw NativeTextEntryIssue.unavailable + } + + @MainActor private func assertNativeMenuCommand(_ element: XCUIElement, in app: XCUIApplication) throws { + // UIKit owns native popup row sizing. Keep app controls on assertTarget's + // 44pt contract and require this exact command in the open Maple menu. + guard element.waitForExistence(timeout: 10) else { + XCTFail("The exact native Settings menu command must be available") + throw NativeTextEntryIssue.unavailable + } + recordTargetGeometry(element, in: app) + guard element.exists, element.identifier == "library.settings", + app.buttons.matching(identifier: "library.settings").count == 1, + element.isEnabled, element.isHittable + else { + XCTFail("The exact native Settings menu command must be available") + throw NativeTextEntryIssue.unavailable + } + let frame = element.frame, window = windowFrame(in: app) + let coordinates = [ + frame.minX, frame.minY, frame.width, frame.height, + window.minX, window.minY, window.width, window.height, + ] + guard coordinates.allSatisfy(\.isFinite), frame.width > 0, frame.height > 0, + window.width > 0, window.height > 0, + window.insetBy(dx: -0.5, dy: -0.5).contains(frame) + else { + XCTFail("The native menu command must have finite visible bounds inside the window") + throw NativeTextEntryIssue.unavailable + } + // These tests pin English; prefix matching also covers doubled labels. + // Observe the other native commands without activating either action. + for title in ["Select chats", "Delete all chats"] { + let commands = app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", title)) + guard commands.count == 1, commands.firstMatch.isEnabled, commands.firstMatch.isHittable else { + XCTFail("Require the currently presented Maple menu commands") + throw NativeTextEntryIssue.unavailable + } + } + } + + @MainActor private func assertTarget(_ element: XCUIElement, in app: XCUIApplication) throws { + recordTargetGeometry(element, in: app) + guard element.exists, element.isHittable else { + XCTFail("Localized control must be visible and reachable") + throw NativeTextEntryIssue.unavailable + } + XCTAssertGreaterThanOrEqual(element.frame.width, 43.5) + XCTAssertGreaterThanOrEqual(element.frame.height, 43.5) + XCTAssertTrue( + windowFrame(in: app).insetBy(dx: -0.5, dy: -0.5).contains(element.frame), + "Control must not overflow the window") + } + + @MainActor private func assertComposerViewport(_ composer: XCUIElement, in app: XCUIApplication) throws { + // Existing chats expose model/project controls only while the native + // editor is focused. Enter that production state before measuring it. + try assertTarget(composer, in: app) + composer.tap() + let window = windowFrame(in: app) + let navigation = app.mapleChatNavigationBar + let keyboard = app.keyboards.firstMatch + guard navigation.exists, keyboard.exists, app.keyboards.count == 1 else { + XCTFail("Require the actual Chat navigation bar and one software keyboard") + throw NativeTextEntryIssue.unavailable + } + let keys = keyboard.buttons.allElementsBoundByIndex + guard keys.count <= 160 else { + XCTFail("Keyboard control inventory exceeds the bounded visibility check") + throw NativeTextEntryIssue.unavailable + } + let visibleKey = keys.first { + $0.exists && $0.isHittable && !$0.frame.intersection(window).isEmpty + } + let bar = navigation.frame, keyboardFrame = keyboard.frame, editor = composer.frame + let required = ["chat.attach", "chat.model", "chat.send"] + let optional = ["chat.project", "dictation.start", "chat.stop"] + let actions = (required + optional).map { ($0, app.buttons[$0].firstMatch) } + .filter { required.contains($0.0) || $0.1.exists } + let frames = actions.map { $0.1.frame } + let allFrames = [window, bar, keyboardFrame, editor] + frames + let finite = allFrames.allSatisfy { + [$0.minX, $0.minY, $0.width, $0.height].allSatisfy { $0.isFinite } + && $0.width > 0 && $0.height > 0 + } + let region = CGRect( + x: window.minX, y: bar.maxY, width: window.width, + height: max(0, keyboardFrame.minY - bar.maxY)) + let reachable = + composer.exists && composer.isHittable + && actions.allSatisfy { $0.1.exists && $0.1.isHittable } + let targets = [editor] + frames + let sized = targets.allSatisfy { $0.width >= 43.5 && $0.height >= 43.5 } + let contained = + finite && region.height > 0 && window.contains(region) + && targets.allSatisfy { region.contains($0) } + let separated = + frames.allSatisfy { $0.minY >= editor.maxY } + && frames.indices.allSatisfy { index in + frames.indices.filter { $0 > index }.allSatisfy { + let overlap = frames[index].intersection(frames[$0]) + return overlap.isNull || overlap.width <= 0 || overlap.height <= 0 + } + } + let clear = visibleKey != nil && finite && reachable && sized && contained && separated + // Public identifiers, booleans and rectangles only; never attach text, + // selected ranges, field values or arbitrary keyboard labels. + var geometry = + "clear=\(clear); visibleKey=\(visibleKey != nil); finite=\(finite); " + + "reachable=\(reachable); sized=\(sized); contained=\(contained); separated=\(separated)\n" + + "window=\(window); navigation=\(bar); keyboard=\(keyboardFrame); editor=\(editor)" + for (action, frame) in zip(actions, frames) { geometry += "\n\(action.0)=\(frame)" } + let attachment = XCTAttachment(string: geometry) + attachment.name = "Localization composer viewport geometry"; attachment.lifetime = .keepAlways + add(attachment) + XCTAssertTrue(clear, "The editor and every composer action must clear Chat navigation and the real keyboard") + guard clear else { throw NativeTextEntryIssue.unavailable } + } + + private func assertDoubled(_ label: String, baseline: String) { + XCTAssertFalse(baseline.isEmpty) + XCTAssertGreaterThanOrEqual(label.count, baseline.count * 2, "Rendered localized label must actually expand") + XCTAssertGreaterThanOrEqual(label.components(separatedBy: baseline).count - 1, 2) + } + + @MainActor private func clearSynthetic(_ field: XCUIElement, in app: XCUIApplication) throws { + guard field.waitForExistence(timeout: 15), let previous = field.value as? String, + previous.utf16.count <= 4_096 + else { throw NativeTextEntryIssue.unavailable } + field.tap() + if !previous.isEmpty { + field.press(forDuration: 1.2) + try editMenu("Select All", in: app).tap() + field.typeText(XCUIKeyboardKey.delete.rawValue) + } + let empty = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in (field.value as? String) == "" }, object: field) + guard XCTWaiter.wait(for: [empty], timeout: 5) == .completed else { + XCTFail("Localized native selection did not clear the complete synthetic field") + throw NativeTextEntryIssue.clearFailed + } + } + + @MainActor private func pasteSynthetic(_ text: String, into field: XCUIElement, in app: XCUIApplication) throws { + try clearSynthetic(field, in: app) + UIPasteboard.general.setItems( + [["public.utf8-plain-text": text]], + options: [.localOnly: true, .expirationDate: Date().addingTimeInterval(120)]) + field.tap(); field.press(forDuration: 1.2) + try editMenu("Paste", in: app).tap() + let exact = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in (field.value as? String) == text }, object: field) + XCTAssertEqual(XCTWaiter.wait(for: [exact], timeout: 10), .completed) + UIPasteboard.general.items = [] + } + + @MainActor private func assertLogicalSelectionCopy(_ text: String, from field: XCUIElement, in app: XCUIApplication) + throws + { + field.tap(); field.press(forDuration: 1.2) + try editMenu("Select All", in: app).tap() + try editMenu("Copy", in: app).tap() + // Verify the exported selection through the app's native Paste path. + // Direct cross-process pasteboard reads can block the test runner. + // Copy retains the native selection. Do not tap the field again: that + // changes the selection and can dismiss the edit menu at large text. + app.typeText(XCUIKeyboardKey.delete.rawValue) + let empty = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in (field.value as? String) == "" }, object: field) + guard XCTWaiter.wait(for: [empty], timeout: 5) == .completed else { + XCTFail("Deleting the copied selection must empty the synthetic field before Paste") + throw NativeTextEntryIssue.clearFailed + } + field.tap(); field.press(forDuration: 1.2) + try editMenu("Paste", in: app).tap() + let restored = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in (field.value as? String)?.isEmpty == false }, object: field) + XCTAssertEqual(XCTWaiter.wait(for: [restored], timeout: 10), .completed) + let copied = try XCTUnwrap(field.value as? String) + XCTAssertEqual( + Data(copied.utf8), Data(text.utf8), + "Native selected Copy and Paste must preserve exact logical scalar order, not only canonical equivalence") + UIPasteboard.general.items = [] + } + + private func hasSystemTitle(_ label: String, title: String) -> Bool { + guard label.contains(title) else { return false } + let rest = label.replacingOccurrences(of: title, with: "") + return rest.unicodeScalars.allSatisfy { + CharacterSet.whitespacesAndNewlines.union(.punctuationCharacters).contains($0) + } + } + + @MainActor private func visibleEditAction(_ title: String, in app: XCUIApplication) -> XCUIElement? { + let menu = app.menuItems.matching(NSPredicate(format: "label CONTAINS %@", title)) + let buttons = app.buttons.matching(NSPredicate(format: "label CONTAINS %@", title)) + return (menu.allElementsBoundByIndex + buttons.allElementsBoundByIndex).first { + $0.isHittable && hasSystemTitle($0.label, title: title) + } + } + + @MainActor private func editMenu(_ title: String, in app: XCUIApplication) throws -> XCUIElement { + _ = app.menuItems.firstMatch.waitForExistence(timeout: 2) + // Expanded native menus show one action per page. The actual failure AX + // exposes Back Back/Forward Forward beside Select Select. Seek from the + // first page, then forward, without substituting a different action. + for direction in ["Back", "Forward"] { + for _ in 0..<8 { + if let action = visibleEditAction(title, in: app) { return action } + let visibleItems = app.menuItems.allElementsBoundByIndex.filter { $0.isHittable } + let bounds = visibleItems.reduce(CGRect.null) { $0.union($1.frame) }.intersection(windowFrame(in: app)) + guard !bounds.isNull, !bounds.isEmpty else { break } + let band = CGRect( + x: windowFrame(in: app).minX, y: bounds.minY, width: windowFrame(in: app).width, + height: bounds.height) + let arrows = app.buttons.matching(NSPredicate(format: "label CONTAINS %@", direction)) + guard + let arrow = arrows.allElementsBoundByIndex.first(where: { + $0.isHittable && $0.isEnabled && hasSystemTitle($0.label, title: direction) + && !$0.frame.intersection(band).isEmpty + }) + else { break } + arrow.tap() + } + } + if let action = visibleEditAction(title, in: app) { return action } + XCTFail("Native edit-menu action unavailable") + throw NativeTextEntryIssue.selectionUnavailable + } + + @MainActor private func capture(_ app: XCUIApplication, name: String) { + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = name; attachment.lifetime = .keepAlways; add(attachment) + } +} diff --git a/apps/maple-ios/UITests/MapleUITests.swift b/apps/maple-ios/UITests/MapleUITests.swift new file mode 100644 index 000000000..863348612 --- /dev/null +++ b/apps/maple-ios/UITests/MapleUITests.swift @@ -0,0 +1,610 @@ +import XCTest +import UIKit + +final class MapleUITests: XCTestCase { + /// A host-side fixture runner may place this file in the test runner's own + /// private container. Credentials never enter app launch arguments, source, + /// assertions or XCTest's typeText activity log. The real form is exercised. + @MainActor func testLocalSignIn() throws { + try XCTSkipUnless(ProcessInfo.processInfo.environment["MAPLE_RUN_LOCAL_UI"] == "1") + struct Fixture: Decodable { let email: String; let password: String } + let root = try FileManager.default.url( + for: .applicationSupportDirectory, in: .userDomainMask, + appropriateFor: nil, create: true) + let file = root.appendingPathComponent("MapleUITestCredentials.json") + let attributes = try FileManager.default.attributesOfItem(atPath: file.path) + guard let size = attributes[.size] as? NSNumber, size.intValue < 4096 else { + XCTFail("Invalid local fixture size"); return + } + let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: file)) + try FileManager.default.removeItem(at: file) + defer { UIPasteboard.general.items = [] } + let app = XCUIApplication() + app.launch() + if app.mapleMenuButton.waitForExistence(timeout: 3) { + let settings = try openLocalSettings(in: app) + let signOut = app.buttons["settings.sign-out"] + try revealLocalControl(signOut, owner: settings, in: app) + signOut.tap() + } + let identifier = app.textFields["auth.identifier"] + XCTAssertTrue(identifier.waitForExistence(timeout: 30)) + try paste(fixture.email, into: identifier, in: app) + try paste(fixture.password, into: app.secureTextFields["auth.password"], in: app) + UIPasteboard.general.items = [] + app.buttons["auth.submit"].tap() + XCTAssertTrue(app.buttons["chat.new"].waitForExistence(timeout: 45)) + XCTAssertFalse(app.staticTexts["auth.error"].exists) + } + + /// Consumes the same private one-shot guest receipt as the browser proof. + /// No new guest is created by the application; no inference is requested. + @MainActor func testLocalExistingGuestSignInUsesSharedGuestReceipt() throws { + try XCTSkipUnless(ProcessInfo.processInfo.environment["MAPLE_RUN_LOCAL_UI"] == "1") + continueAfterFailure = false + struct Fixture: Decodable { let id: String; let password: String } + let root = try FileManager.default.url( + for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + let file = root.appendingPathComponent("MapleUITestGuestCredentials.json") + let attributes = try FileManager.default.attributesOfItem(atPath: file.path) + guard let size = attributes[.size] as? NSNumber, size.intValue > 0, size.intValue < 4096 else { + XCTFail("Invalid local guest fixture size"); return + } + let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: file)) + try FileManager.default.removeItem(at: file) + guard UUID(uuidString: fixture.id) != nil, !fixture.password.isEmpty else { + XCTFail("Invalid local guest fixture shape"); return + } + defer { UIPasteboard.general.items = [] } + let app = XCUIApplication(); app.launch() + if app.mapleMenuButton.waitForExistence(timeout: 3) { + let settings = try openLocalSettings(in: app) + let signOut = app.buttons["settings.sign-out"] + try revealLocalControl(signOut, owner: settings, in: app) + signOut.tap() + } + let guestMode = app.buttons["Use an existing guest account"] + XCTAssertTrue(guestMode.waitForExistence(timeout: 30)) + if !guestMode.isHittable { app.swipeUp() } + XCTAssertTrue(guestMode.isHittable); guestMode.tap() + let identifier = app.textFields["auth.identifier"] + XCTAssertTrue(identifier.waitForExistence(timeout: 10)) + if !identifier.isHittable { app.swipeDown() } + try paste(fixture.id, into: identifier, in: app) + try paste(fixture.password, into: app.secureTextFields["auth.password"], in: app) + UIPasteboard.general.items = [] + app.buttons["auth.submit"].tap() + XCTAssertTrue(app.buttons["chat.new"].waitForExistence(timeout: 45)) + XCTAssertFalse(app.staticTexts["auth.error"].exists) + let settings = try openLocalSettings(in: app) + let guest = app.staticTexts["Guest account"] + try revealLocalControl(guest, owner: settings, in: app) + let capture = XCTAttachment(screenshot: app.screenshot()) + capture.name = "Reference — real local existing guest — ordinary native account settings" + capture.lifetime = .keepAlways; add(capture) + let signOut = app.buttons["settings.sign-out"] + try revealLocalControl(signOut, owner: settings, in: app) + signOut.tap() + XCTAssertTrue(app.textFields["auth.identifier"].waitForExistence(timeout: 20)) + } + + @MainActor private func openLocalSettings(in app: XCUIApplication) throws -> XCUIElement { + app.mapleMenuButton.tap() + func library() -> XCUIElement? { + app.collectionViews.allElementsBoundByIndex.first { owner in + let region = owner.frame.intersection(app.frame) + guard region.width >= 100, region.height >= 100 else { return false } + return owner.descendants(matching: .any).matching( + NSPredicate(format: "identifier BEGINSWITH %@", "library.") + ).allElementsBoundByIndex.prefix(512).contains { + $0.isHittable && !$0.frame.intersection(region).isEmpty + } + } + } + let visible = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in library() != nil }, object: app) + XCTAssertEqual(XCTWaiter.wait(for: [visible], timeout: 20), .completed) + _ = try XCTUnwrap(library()) + let actions = app.buttons["library.actions"] + XCTAssertTrue(actions.waitForExistence(timeout: 20)); XCTAssertTrue(actions.isHittable) + XCTAssertTrue(actions.isEnabled); actions.tap() + let settings = app.buttons["library.settings"] + XCTAssertTrue(settings.waitForExistence(timeout: 20)); XCTAssertTrue(settings.isHittable) + XCTAssertTrue(settings.isEnabled); settings.tap() + let form = app.collectionViews["settings.screen"] + XCTAssertTrue(form.waitForExistence(timeout: 15)) + return form + } + + @MainActor private func revealLocalControl( + _ element: XCUIElement, owner: XCUIElement, in app: XCUIApplication, attempts: Int = 8 + ) throws { + for _ in 0..= 44, region.height >= 100 else { + XCTFail("Local proof owning list is unavailable"); throw NativeTextEntryIssue.unavailable + } + let origin = app.coordinate(withNormalizedOffset: .zero) + origin.withOffset( + CGVector(dx: region.midX - app.frame.minX, dy: region.maxY - region.height * 0.15 - app.frame.minY) + ) + .press( + forDuration: 0.05, + thenDragTo: origin.withOffset( + CGVector(dx: region.midX - app.frame.minX, dy: region.minY + region.height * 0.15 - app.frame.minY)) + ) + } + XCTAssertTrue(element.exists && element.isHittable) + guard element.exists && element.isHittable else { throw NativeTextEntryIssue.unavailable } + } + + @MainActor private func paste(_ value: String, into field: XCUIElement, in app: XCUIApplication) throws { + UIPasteboard.general.setItems( + [["public.utf8-plain-text": value]], + options: [.localOnly: true, .expirationDate: Date().addingTimeInterval(30)]) + field.tap() + field.press(forDuration: 1.2) + let paste = app.menuItems["Paste"] + if paste.waitForExistence(timeout: 3) { + paste.tap() + } else { + let button = app.buttons["Paste"] + XCTAssertTrue(button.waitForExistence(timeout: 3)) + button.tap() + } + } + + @MainActor func testConfiguredAppOpens() throws { + let app = XCUIApplication() + app.launchArguments = ["--maple-startup-diagnostic"] + app.launch() + let login = app.textFields["auth.identifier"] + let chat = app.buttons["chat.new"] + let available = NSPredicate { _, _ in login.exists || chat.exists } + expectation(for: available, evaluatedWith: app) + waitForExpectations(timeout: 30) + XCTAssertFalse(app.staticTexts["Configuration unavailable"].exists) + } + + /// Opt in only after signing the dedicated local fixture into this exact + /// simulator. No credentials are passed through XCTest or its activity log. + @MainActor func testLocalChatSendAndRetainedDraft() throws { + try XCTSkipUnless(ProcessInfo.processInfo.environment["MAPLE_RUN_LOCAL_UI"] == "1") + let app = XCUIApplication() + app.launch() + XCTAssertTrue(app.buttons["chat.new"].waitForExistence(timeout: 30)) + app.buttons["chat.new"].tap() + let composer = app.textViews["chat.composer"] + XCTAssertTrue(composer.waitForExistence(timeout: 10)) + try replaceSyntheticText(composer, with: "Reply with exactly: Maple local smoke passed.", in: app) + app.buttons["chat.send"].tap() + let sent = NSPredicate { _, _ in + app.descendants(matching: .any).matching( + NSPredicate(format: "identifier BEGINSWITH %@", "assistant.message.") + ).count > 0 + && !app.buttons["chat.stop"].exists + } + expectation(for: sent, evaluatedWith: app) + waitForExpectations(timeout: 90) + XCTAssertFalse(app.staticTexts["chat.error"].exists) + composer.tap() + composer.typeText("A retained local draft") + app.mapleMenuButton.tap() + XCTAssertTrue(app.buttons["library.close"].waitForExistence(timeout: 10)) + app.buttons["library.close"].tap() + XCTAssertEqual(composer.value as? String, "A retained local draft") + let screenshot = XCTAttachment(screenshot: app.screenshot()) + screenshot.name = "Local chat and retained draft" + screenshot.lifetime = .keepAlways + add(screenshot) + } + + @MainActor func testLocalReplyAudioSurvivesNavigationAndCloses() throws { + try XCTSkipUnless(ProcessInfo.processInfo.environment["MAPLE_RUN_LOCAL_UI"] == "1") + let app = XCUIApplication(); app.launch() + XCTAssertTrue(app.buttons["chat.new"].waitForExistence(timeout: 30)) + app.buttons["chat.new"].tap() + let composer = app.textViews["chat.composer"] + XCTAssertTrue(composer.waitForExistence(timeout: 10)) + try replaceSyntheticText( + composer, + with: + "Reply with exactly: Maple can read this synthetic local test reply aloud. The player keeps its current reply when the menu opens and closes.", + in: app) + app.buttons["chat.send"].tap() + let speaker = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH %@", "message.speak.")).firstMatch + let completed = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in speaker.exists && speaker.isEnabled && !app.buttons["chat.stop"].exists }, + object: app) + XCTAssertEqual(XCTWaiter.wait(for: [completed], timeout: 90), .completed) + try revealReplyAction(speaker, above: composer, in: app) + speaker.tap() + XCTAssertTrue(app.buttons["audio.close"].waitForExistence(timeout: 15)) + let ready = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in app.buttons["audio.back"].isEnabled }, object: app) + XCTAssertEqual(XCTWaiter.wait(for: [ready], timeout: 60), .completed) + let transport = app.buttons["audio.play-pause"] + if transport.label == "Pause reply audio" { transport.tap() } + app.buttons["audio.back"].tap() + app.buttons["audio.speed"].tap() + let speed = app.buttons["1.5×"] + XCTAssertTrue(speed.waitForExistence(timeout: 5)); speed.tap() + app.mapleMenuButton.tap() + XCTAssertTrue(app.buttons["audio.close"].waitForExistence(timeout: 10)) + let screenshot = XCTAttachment(screenshot: app.screenshot()) + screenshot.name = "Local reply player in menu"; screenshot.lifetime = .keepAlways; add(screenshot) + app.buttons["library.close"].tap() + XCTAssertTrue(app.buttons["audio.close"].waitForExistence(timeout: 10)) + app.buttons["audio.close"].tap() + XCTAssertFalse(app.buttons["audio.close"].waitForExistence(timeout: 2)) + } + + @MainActor private func revealReplyAction( + _ action: XCUIElement, above composer: XCUIElement, in app: XCUIApplication + ) throws { + for _ in 0..<8 { + let top = app.navigationBars.firstMatch.frame.maxY + 12 + let bottom = composer.frame.minY - 24 + guard bottom - top >= 90 else { throw NativeTextEntryIssue.unavailable } + if action.isHittable, action.frame.minY >= top, action.frame.maxY <= bottom { return } + let origin = app.coordinate(withNormalizedOffset: .zero) + let x = app.frame.midX + origin.withOffset(CGVector(dx: x, dy: bottom - 12)).press( + forDuration: 0.05, + thenDragTo: origin.withOffset(CGVector(dx: x, dy: top + 12))) + } + XCTFail("Reply action did not become visible above the composer") + throw NativeTextEntryIssue.unavailable + } + + @MainActor func testLocalExplicitImagePastePreviewAndRemoval() throws { + try XCTSkipUnless(ProcessInfo.processInfo.environment["MAPLE_RUN_LOCAL_UI"] == "1") + let app = XCUIApplication(); app.launch() + XCTAssertTrue(app.buttons["chat.new"].waitForExistence(timeout: 30)) + app.buttons["chat.new"].tap() + let composer = app.textViews["chat.composer"] + XCTAssertTrue(composer.waitForExistence(timeout: 10)) + composer.tap(); composer.typeText(" Image fixture draft.") + let draft = composer.value as? String + let image = UIGraphicsImageRenderer(size: CGSize(width: 800, height: 600)).image { context in + UIColor.white.setFill(); context.fill(CGRect(x: 0, y: 0, width: 800, height: 600)) + ("Maple synthetic image\nReadable screenshot text 12345" as NSString).draw( + in: CGRect(x: 40, y: 100, width: 720, height: 400), + withAttributes: [.font: UIFont.systemFont(ofSize: 36), .foregroundColor: UIColor.black]) + } + let data = try XCTUnwrap(image.pngData()) + UIPasteboard.general.setItems( + [["public.png": data]], + options: [ + .localOnly: true, .expirationDate: Date().addingTimeInterval(120), + ]) + defer { UIPasteboard.general.items = [] } + app.buttons["chat.attach"].tap() + app.buttons["Paste image"].tap() + XCTAssertTrue(app.buttons["images.paste"].waitForExistence(timeout: 10)) + app.buttons["images.paste"].tap() + let attached = app.buttons["View attached image"].firstMatch + XCTAssertTrue(attached.waitForExistence(timeout: 20)) + XCTAssertEqual(composer.value as? String, draft) + attached.tap() + XCTAssertTrue(app.buttons["images.close"].waitForExistence(timeout: 10)) + let screenshot = XCTAttachment(screenshot: app.screenshot()) + screenshot.name = "Synthetic pasted image preview"; screenshot.lifetime = .keepAlways; add(screenshot) + app.pinch(withScale: 2, velocity: 1) + app.buttons["images.close"].tap() + let remove = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH %@", "images.remove.")).firstMatch + XCTAssertTrue(remove.waitForExistence(timeout: 5)); remove.tap() + XCTAssertFalse(attached.waitForExistence(timeout: 2)) + XCTAssertEqual(composer.value as? String, draft) + } + @MainActor func testLocalNavigationSettingsAndRetainedDraft() throws { + try XCTSkipUnless(ProcessInfo.processInfo.environment["MAPLE_RUN_LOCAL_UI"] == "1") + let app = XCUIApplication(); app.launch() + XCTAssertTrue(app.buttons["chat.new"].waitForExistence(timeout: 30)) + app.buttons["chat.new"].tap() + let composer = app.textViews["chat.composer"] + XCTAssertTrue(composer.waitForExistence(timeout: 10)) + composer.tap(); composer.typeText(" Navigation retains this draft.") + let draft = composer.value as? String + let settings = try openLocalSettings(in: app) + let instructions = app.buttons["Default instructions"] + try revealLocalControl(instructions, owner: settings, in: app) + instructions.tap() + let editor = app.textViews["library.instructions-text"] + XCTAssertTrue(editor.waitForExistence(timeout: 15)) + editor.tap(); editor.typeText(" Unsaved local navigation fixture.") + app.buttons["library.instructions-cancel"].tap() + XCTAssertTrue(app.buttons["Discard changes"].waitForExistence(timeout: 5)) + app.buttons["Discard changes"].tap() + XCTAssertFalse(editor.waitForExistence(timeout: 1)) + let screen = XCTAttachment(screenshot: app.screenshot()) + screen.name = "Native local settings"; screen.lifetime = .keepAlways; add(screen) + app.navigationBars.buttons.element(boundBy: 0).tap() + XCTAssertTrue(app.buttons["library.close"].waitForExistence(timeout: 10)) + app.buttons["library.close"].tap() + XCTAssertTrue(composer.waitForExistence(timeout: 5)) + XCTAssertEqual(composer.value as? String, draft) + } + + @MainActor func testLocalColdLaunchAndForegroundPreserveDistinctDrafts() throws { + try XCTSkipUnless(ProcessInfo.processInfo.environment["MAPLE_RUN_LOCAL_UI"] == "1") + continueAfterFailure = false + let conversationID = try coldLaunchConversationID() + let app = XCUIApplication(); app.launch() + XCTAssertTrue(app.buttons["chat.new"].waitForExistence(timeout: 30)) + app.buttons["chat.new"].tap() + let composer = app.textViews["chat.composer"] + try replaceSyntheticDraft(composer, with: "Saved New Chat draft for cold launch.") + try openColdLaunchConversation(conversationID, in: app) + try replaceSyntheticDraft(composer, with: "Existing chat survives foreground.") + // Dismiss the software keyboard and keep a visible message as a geometry + // witness across a process-alive background/foreground transition. + app.scrollViews["chat.transcript"].swipeDown() + let message = app.descendants(matching: .any).matching( + NSPredicate(format: "identifier BEGINSWITH %@", "assistant.message.") + ).firstMatch + XCTAssertTrue(message.waitForExistence(timeout: 15)) + let visible = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + message.frame.width > 0 && message.frame.height > 0 + && !message.frame.intersection(app.frame).isEmpty + }, object: message) + XCTAssertEqual(XCTWaiter.wait(for: [visible], timeout: 15), .completed) + // Rich replies may finish laying out bundled equations after becoming visible. + // Measure a stable native frame before attributing movement to the lifecycle transition. + var lastFrame = message.frame + var stableSince = Date() + let settled = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + let frame = message.frame + guard frame.width > 0, frame.height > 0, !frame.intersection(app.frame).isEmpty else { + lastFrame = frame + stableSince = Date() + return false + } + let delta = max( + abs(frame.minX - lastFrame.minX), abs(frame.minY - lastFrame.minY), + abs(frame.width - lastFrame.width), abs(frame.height - lastFrame.height)) + if delta > 0.5 { + lastFrame = frame + stableSince = Date() + } + return Date().timeIntervalSince(stableSince) >= 2 + }, object: message) + XCTAssertEqual(XCTWaiter.wait(for: [settled], timeout: 15), .completed) + let before = message.frame + XCUIDevice.shared.press(.home) + app.activate() + XCTAssertTrue(composer.waitForExistence(timeout: 15)) + XCTAssertEqual(composer.value as? String, "Existing chat survives foreground.") + XCTAssertEqual(message.frame.minY, before.minY, accuracy: 3) + XCTAssertEqual(message.frame.height, before.height, accuracy: 3) + app.mapleMenuButton.tap() + app.buttons["library.close"].tap() + XCTAssertEqual(composer.value as? String, "Existing chat survives foreground.") + app.terminate(); app.launch() + let newChatRestored = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + composer.exists && composer.value as? String == "Saved New Chat draft for cold launch." + }, object: composer) + XCTAssertEqual(XCTWaiter.wait(for: [newChatRestored], timeout: 30), .completed) + XCTAssertEqual(composer.value as? String, "Saved New Chat draft for cold launch.") + try openColdLaunchConversation(conversationID, in: app) + let existingChatRestored = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + composer.exists && composer.value as? String == "Existing chat survives foreground." + }, object: composer) + XCTAssertEqual(XCTWaiter.wait(for: [existingChatRestored], timeout: 10), .completed) + XCTAssertEqual(composer.value as? String, "Existing chat survives foreground.") + let screenshot = XCTAttachment(screenshot: app.screenshot()) + screenshot.name = "Reopened existing chat retains its distinct draft" + screenshot.lifetime = .keepAlways; add(screenshot) + } + + @MainActor private func coldLaunchConversationID() throws -> String { + struct Fixture: Decodable { let conversationID: String } + let support = try FileManager.default.url( + for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: false) + let file = support.appendingPathComponent("MapleUITestReferenceChat.json") + let attributes = try FileManager.default.attributesOfItem(atPath: file.path) + guard attributes[.type] as? FileAttributeType == .typeRegular, + let size = attributes[.size] as? NSNumber, size.intValue > 0, size.intValue <= 1024 + else { throw NativeTextEntryIssue.unavailable } + let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: file)) + try FileManager.default.removeItem(at: file) + guard UUID(uuidString: fixture.conversationID) != nil else { throw NativeTextEntryIssue.unavailable } + return fixture.conversationID + } + + @MainActor private func openColdLaunchConversation(_ id: String, in app: XCUIApplication) throws { + let menu = app.mapleMenuButton + XCTAssertTrue(menu.waitForExistence(timeout: 10)); XCTAssertTrue(menu.isHittable); menu.tap() + guard let row = try MenuChatUI.find(id, in: app, pageLimit: 24) else { + XCTFail("The exact shared rich conversation must be selected; never substitute the latest row") + throw NativeTextEntryIssue.unavailable + } + XCTAssertEqual(row.identifier, "library.chat." + id) + XCTAssertTrue(row.isHittable); row.tap() + XCTAssertTrue(app.textViews["chat.composer"].waitForExistence(timeout: 20)) + let message = app.descendants(matching: .any).matching( + NSPredicate(format: "identifier BEGINSWITH %@", "assistant.message.") + ).firstMatch + XCTAssertTrue(message.waitForExistence(timeout: 30)) + XCTAssertFalse(app.staticTexts["chat.error"].exists) + } + + @MainActor private func replaceSyntheticDraft(_ composer: XCUIElement, with text: String) throws { + try replaceSyntheticText(composer, with: text, in: XCUIApplication()) + } + + @MainActor func testLocalSignOutClearsTextImagesAndSavedDraft() throws { + try XCTSkipUnless(ProcessInfo.processInfo.environment["MAPLE_RUN_LOCAL_UI"] == "1") + struct Fixture: Decodable { let email: String; let password: String } + let root = try FileManager.default.url( + for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + let file = root.appendingPathComponent("MapleUITestCredentials.json") + let attributes = try FileManager.default.attributesOfItem(atPath: file.path) + guard let size = attributes[.size] as? NSNumber, size.intValue < 4096 else { + XCTFail("Invalid local fixture size"); return + } + let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: file)) + try FileManager.default.removeItem(at: file) + defer { UIPasteboard.general.items = [] } + let app = XCUIApplication(); app.launch() + XCTAssertTrue(app.buttons["chat.new"].waitForExistence(timeout: 30)) + app.buttons["chat.new"].tap() + let composer = app.textViews["chat.composer"] + try replaceSyntheticDraft(composer, with: "Private synthetic draft cleared by sign out.") + let image = UIGraphicsImageRenderer(size: CGSize(width: 200, height: 100)).image { context in + UIColor.systemBlue.setFill(); context.fill(CGRect(x: 0, y: 0, width: 200, height: 100)) + } + UIPasteboard.general.setItems( + [["public.png": try XCTUnwrap(image.pngData())]], + options: [.localOnly: true, .expirationDate: Date().addingTimeInterval(120)]) + app.buttons["chat.attach"].tap(); app.buttons["Paste image"].tap() + XCTAssertTrue(app.buttons["images.paste"].waitForExistence(timeout: 10)) + app.buttons["images.paste"].tap() + XCTAssertTrue(app.buttons["View attached image"].firstMatch.waitForExistence(timeout: 20)) + UIPasteboard.general.items = [] + let settings = try openLocalSettings(in: app) + let signOut = app.buttons["settings.sign-out"] + try revealLocalControl(signOut, owner: settings, in: app) + signOut.tap() + let identifier = app.textFields["auth.identifier"] + XCTAssertTrue(identifier.waitForExistence(timeout: 30)) + XCTAssertFalse(app.alerts.firstMatch.exists, "Ordinary draft sign out must not ask for confirmation") + try paste(fixture.email, into: identifier, in: app) + try paste(fixture.password, into: app.secureTextFields["auth.password"], in: app) + UIPasteboard.general.items = [] + app.buttons["auth.submit"].tap() + XCTAssertTrue(composer.waitForExistence(timeout: 45)) + XCTAssertEqual(composer.value as? String, "") + XCTAssertFalse(app.buttons["View attached image"].firstMatch.exists) + XCTAssertFalse(app.buttons["queue.open"].exists) + app.terminate(); app.launch() + XCTAssertTrue(composer.waitForExistence(timeout: 30)) + XCTAssertEqual(composer.value as? String, "") + XCTAssertFalse(app.buttons["View attached image"].firstMatch.exists) + } + + @MainActor func testLocalImagePickerCancellationAndUnavailableCameraPreserveDraft() throws { + try XCTSkipUnless(ProcessInfo.processInfo.environment["MAPLE_RUN_LOCAL_UI"] == "1") + let app = XCUIApplication(); app.launch() + XCTAssertTrue(app.buttons["chat.new"].waitForExistence(timeout: 30)) + app.buttons["chat.new"].tap() + let composer = app.textViews["chat.composer"] + try replaceSyntheticDraft(composer, with: "Picker cancellation preserves this draft.") + let count = app.buttons.matching(identifier: "View attached image").count + for source in ["Photo library", "Files"] { + app.buttons["chat.attach"].tap(); app.buttons[source].tap() + let presentedHierarchy = XCTAttachment(string: app.debugDescription) + presentedHierarchy.name = "Synthetic image picker after \(source) tap hierarchy" + presentedHierarchy.lifetime = .keepAlways; add(presentedHierarchy) + let presentedScreen = XCTAttachment(screenshot: app.screenshot()) + presentedScreen.name = "Synthetic image picker after \(source) tap" + presentedScreen.lifetime = .keepAlways; add(presentedScreen) + let cancel = app.buttons["Cancel"].firstMatch + let cancelAppeared = cancel.waitForExistence(timeout: 15) + if !cancelAppeared { + let missingCancelHierarchy = XCTAttachment(string: app.debugDescription) + missingCancelHierarchy.name = "Synthetic image picker \(source) missing Cancel hierarchy" + missingCancelHierarchy.lifetime = .keepAlways; add(missingCancelHierarchy) + } + XCTAssertTrue(cancelAppeared); cancel.tap() + XCTAssertTrue(composer.waitForExistence(timeout: 10)) + XCTAssertEqual(composer.value as? String, "Picker cancellation preserves this draft.") + XCTAssertEqual(app.buttons.matching(identifier: "View attached image").count, count) + XCTAssertFalse(app.staticTexts["images.error"].exists) + } + app.buttons["chat.attach"].tap(); app.buttons["Take photo"].tap() + XCTAssertTrue(app.staticTexts["images.error"].waitForExistence(timeout: 10)) + XCTAssertEqual(composer.value as? String, "Picker cancellation preserves this draft.") + XCTAssertEqual(app.buttons.matching(identifier: "View attached image").count, count) + } + + @MainActor func testLocalImageTenAttachmentLimitAndAtomicElevenRejection() throws { + try XCTSkipUnless(ProcessInfo.processInfo.environment["MAPLE_RUN_LOCAL_UI"] == "1") + let app = XCUIApplication(); app.launch() + XCTAssertTrue(app.buttons["chat.new"].waitForExistence(timeout: 30)) + app.buttons["chat.new"].tap() + let composer = app.textViews["chat.composer"] + try replaceSyntheticDraft(composer, with: "Atomic image limit draft.") + removeSyntheticAttachments(in: app) + let image = UIGraphicsImageRenderer(size: CGSize(width: 160, height: 100)).image { context in + UIColor.white.setFill(); context.fill(CGRect(x: 0, y: 0, width: 160, height: 100)) + ("SYNTHETIC" as NSString).draw( + at: CGPoint(x: 10, y: 30), + withAttributes: [.font: UIFont.systemFont(ofSize: 20), .foregroundColor: UIColor.black]) + } + let data = try XCTUnwrap(image.pngData()) + defer { UIPasteboard.general.items = [] } + pasteSyntheticImages(Array(repeating: data, count: 11), in: app) + XCTAssertTrue(app.staticTexts["images.error"].waitForExistence(timeout: 20)) + XCTAssertEqual(app.buttons.matching(identifier: "View attached image").count, 0) + XCTAssertEqual(composer.value as? String, "Atomic image limit draft.") + pasteSyntheticImages(Array(repeating: data, count: 10), in: app) + let imported = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + app.buttons.matching(identifier: "View attached image").count == 10 + }, object: app) + XCTAssertEqual(XCTWaiter.wait(for: [imported], timeout: 30), .completed) + app.buttons["chat.attach"].tap(); app.buttons["Paste image"].tap() + XCTAssertTrue(app.staticTexts["images.error"].waitForExistence(timeout: 10)) + XCTAssertFalse(app.buttons["images.paste"].exists) + XCTAssertEqual(app.buttons.matching(identifier: "View attached image").count, 10) + XCTAssertEqual(composer.value as? String, "Atomic image limit draft.") + removeSyntheticAttachments(in: app) + } + + @MainActor func testLocalDenseScreenshotPastePreviewPreservesSource() throws { + try XCTSkipUnless(ProcessInfo.processInfo.environment["MAPLE_RUN_LOCAL_UI"] == "1") + let root = try FileManager.default.url( + for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: false) + let source = root.appendingPathComponent("MapleUITestDenseScreenshot.png") + let data = try Data(contentsOf: source) + XCTAssertLessThanOrEqual(data.count, 20 * 1024 * 1024) + let sourceImage = try XCTUnwrap(UIImage(data: data)?.cgImage) + XCTAssertEqual(sourceImage.width, 5120); XCTAssertEqual(sourceImage.height, 3200) + let app = XCUIApplication(); app.launch() + XCTAssertTrue(app.buttons["chat.new"].waitForExistence(timeout: 30)) + app.buttons["chat.new"].tap() + let composer = app.textViews["chat.composer"] + try replaceSyntheticDraft(composer, with: "Dense screenshot fixture draft.") + removeSyntheticAttachments(in: app) + defer { UIPasteboard.general.items = [] } + pasteSyntheticImages([data], in: app) + let image = app.buttons["View attached image"].firstMatch + XCTAssertTrue(image.waitForExistence(timeout: 30)); image.tap() + XCTAssertTrue(app.buttons["images.close"].waitForExistence(timeout: 10)) + let before = XCTAttachment(screenshot: app.screenshot()) + before.name = "Downscaled dense screenshot fitted preview"; before.lifetime = .keepAlways; add(before) + app.pinch(withScale: 5, velocity: 1) + let zoomed = XCTAttachment(screenshot: app.screenshot()) + zoomed.name = "Downscaled dense screenshot zoomed for legibility"; zoomed.lifetime = .keepAlways; add(zoomed) + app.buttons["images.close"].tap() + XCTAssertEqual(composer.value as? String, "Dense screenshot fixture draft.") + XCTAssertEqual(try Data(contentsOf: source), data, "Import and preview must preserve the source file") + removeSyntheticAttachments(in: app) + } + + @MainActor private func pasteSyntheticImages(_ images: [Data], in app: XCUIApplication) { + UIPasteboard.general.setItems( + images.map { ["public.png": $0] }, + options: [.localOnly: true, .expirationDate: Date().addingTimeInterval(120)]) + app.buttons["chat.attach"].tap(); app.buttons["Paste image"].tap() + XCTAssertTrue(app.buttons["images.paste"].waitForExistence(timeout: 10)) + app.buttons["images.paste"].tap() + } + + @MainActor private func removeSyntheticAttachments(in app: XCUIApplication) { + for _ in 0..<10 { + let remove = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH %@", "images.remove.")) + .firstMatch + if !remove.exists { break } + if !remove.isHittable { app.swipeDown() } + remove.tap() + } + XCTAssertEqual(app.buttons.matching(identifier: "View attached image").count, 0) + } + +} diff --git a/apps/maple-ios/UITests/MenuChatUI.swift b/apps/maple-ios/UITests/MenuChatUI.swift new file mode 100644 index 000000000..67553dcae --- /dev/null +++ b/apps/maple-ios/UITests/MenuChatUI.swift @@ -0,0 +1,296 @@ +import Foundation +import XCTest + +/// Resolves only the requested UUID through real Projects/Pinned/Recent rows. +/// Paging and disclosure stay in the menu; no API reads or row substitution. +@MainActor enum MenuChatUI { + static func find( + _ id: String, in app: XCUIApplication, projectIDs: [String]? = nil, pageLimit: Int + ) throws -> XCUIElement? { + guard UUID(uuidString: id) != nil, (1...24).contains(pageLimit) else { + throw NativeTextEntryIssue.unavailable + } + let root = try rootList(in: app) + try settle(root) + // The menu retains its scroll position after chat selection. Start each + // exact lookup at the top before scanning its existing page owners. + try reveal(root.buttons["library.new-chat"], in: app, owner: root, towardTop: true, limit: pageLimit) + try settle(root) + if let row = try scan( + id, in: app, owner: root, + pageControls: ["library.pinned.load-more", "library.recent.load-more"], limit: pageLimit) + { + return row + } + let candidates: [String] + if let projectIDs { + guard projectIDs.count <= 16, projectIDs.allSatisfy({ UUID(uuidString: $0) != nil }) else { + throw NativeTextEntryIssue.unavailable + } + candidates = Array(Set(projectIDs)).sorted() + } else { + // Projects may load after the first root snapshot. Discover only + // after the root scan and a real bounded return to the top. + try reveal(root.buttons["library.new-chat"], in: app, owner: root, towardTop: true, limit: pageLimit) + try settle(root) + var found = Set(), previous: Set?, stable = 0, reachedEnd = false + for _ in 0..= 2 { reachedEnd = true; break } + previous = visible + try scroll(in: app, owner: root, towardTop: false) + try settle(root) + } + guard reachedEnd else { throw NativeTextEntryIssue.unavailable } + candidates = found.sorted() + } + for projectID in candidates { + try expand(projectID, in: app, pageLimit: pageLimit) + if let row = try scan( + id, in: app, owner: root, + pageControls: ["library.project.load-more." + projectID], limit: pageLimit) + { + return row + } + } + return nil + } + + static func expand(_ projectID: String, in app: XCUIApplication, pageLimit: Int) throws { + guard UUID(uuidString: projectID) != nil, (1...24).contains(pageLimit) else { + throw NativeTextEntryIssue.unavailable + } + let root = try rootList(in: app) + try reveal(root.buttons["library.new-chat"], in: app, owner: root, towardTop: true, limit: pageLimit) + try settle(root) + let folder = root.buttons.matching(identifier: "library.project." + projectID).firstMatch + try reveal(folder, in: app, owner: root, towardTop: false, limit: pageLimit) + guard let state = folder.value as? String, state == "Expanded" || state == "Collapsed" else { + throw NativeTextEntryIssue.unavailable + } + var remainingScrolls = pageLimit + if state == "Collapsed" { + // Require the whole header and child margin inside the same clear band + // used by the capped gesture before deciding to toggle the disclosure. + var childRoom = false + for _ in 0.. 0, frame.height > 0 + else { throw NativeTextEntryIssue.unavailable } + if folder.isHittable, viewport.contains(frame), frame.maxY + 44 <= viewport.maxY { + childRoom = true; break + } + towardTop = frame.minY < viewport.minY + } + remainingScrolls -= 1 + try scroll(in: app, owner: root, towardTop: towardTop, maximumDistance: 44) + try settle(root) + } + guard childRoom else { throw NativeTextEntryIssue.unavailable } + folder.tap() + } + let expanded = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in folder.exists && folder.value as? String == "Expanded" }, object: folder) + guard XCTWaiter.wait(for: [expanded], timeout: 15) == .completed else { + throw NativeTextEntryIssue.unavailable + } + try settle(root) + // Position the expanded children using the remaining disclosure budget. + // The header is the stable anchor; Manage now lives in its context menu. + // Empty projects remain valid; no conversation row is required here. + for _ in 0...remainingScrolls { + let viewport = try childRoomRegion(in: app, owner: root) + guard folder.exists, folder.value as? String == "Expanded" else { + throw NativeTextEntryIssue.unavailable + } + let frame = folder.frame + guard [frame.minX, frame.minY, frame.maxX, frame.maxY].allSatisfy({ $0.isFinite }), + frame.width > 0, frame.height > 0 + else { throw NativeTextEntryIssue.unavailable } + if folder.isHittable, viewport.contains(frame), frame.minY <= viewport.minY + 44 { + return + } + guard remainingScrolls > 0 else { throw NativeTextEntryIssue.unavailable } + remainingScrolls -= 1 + try scroll(in: app, owner: root, towardTop: frame.minY < viewport.minY, maximumDistance: 44) + try settle(root) + } + } + + private static func rootList(in app: XCUIApplication) throws -> XCUIElement { + let root = app.collectionViews["library.sidebar-list"] + guard root.waitForExistence(timeout: 15), app.windows.firstMatch.exists, + root.frame.intersection(app.windows.firstMatch.frame).width >= 100, + root.frame.intersection(app.windows.firstMatch.frame).height >= 100 + else { throw NativeTextEntryIssue.unavailable } + return root + } + + private static func settle(_ owner: XCUIElement) throws { + let loaded = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + owner.exists && owner.activityIndicators.allElementsBoundByIndex.allSatisfy { !$0.exists } + }, object: owner) + guard XCTWaiter.wait(for: [loaded], timeout: 15) == .completed else { + throw NativeTextEntryIssue.unavailable + } + } + + private static func visibleIDs(_ owner: XCUIElement, in app: XCUIApplication) -> Set { + let region = owner.frame.intersection(app.windows.firstMatch.frame) + return Set( + owner.descendants(matching: .any).allElementsBoundByIndex.compactMap { item in + item.exists && item.isHittable && item.frame.intersects(region) && !item.identifier.isEmpty + ? item.identifier : nil + }) + } + + private static func scan( + _ id: String, in app: XCUIApplication, owner: XCUIElement, pageControls: [String], limit: Int + ) throws -> XCUIElement? { + func visibleRow() -> XCUIElement? { + owner.buttons.matching(identifier: "library.chat." + id).allElementsBoundByIndex.first { + $0.exists && $0.isHittable && $0.isEnabled + && $0.frame.intersects(owner.frame.intersection(app.windows.firstMatch.frame)) + } + } + for _ in 0.. 0 else { throw NativeTextEntryIssue.unavailable } + let region = try childRoomRegion(in: app, owner: owner) + let top = CGPoint(x: region.midX, y: region.minY + region.height * 0.15) + let bottom = CGPoint(x: region.midX, y: region.maxY - region.height * 0.15) + let distance = min(maximumDistance, bottom.y - top.y) + let start = towardTop ? top : bottom + let end = CGPoint(x: start.x, y: start.y + (towardTop ? distance : -distance)) + let origin = window.coordinate(withNormalizedOffset: .zero) + let startPoint = origin.withOffset( + CGVector(dx: start.x - window.frame.minX, dy: start.y - window.frame.minY)) + let endPoint = origin.withOffset( + CGVector(dx: end.x - window.frame.minX, dy: end.y - window.frame.minY)) + startPoint.press( + forDuration: 0.05, thenDragTo: endPoint, + withVelocity: XCUIGestureVelocity(rawValue: 100), thenHoldForDuration: 0.1) + return + } + var region = owner.frame.intersection(window.frame).insetBy(dx: 16, dy: 24) + let search = app.textFields["library.search"] + if search.exists, search.frame.intersects(region) { + region.size.height = max(0, search.frame.minY - region.minY - 8) + } + for keyboard in app.keyboards.allElementsBoundByIndex where keyboard.exists { + let overlap = keyboard.frame.intersection(region) + if !overlap.isEmpty { region.size.height = max(0, overlap.minY - region.minY - 16) } + } + guard !region.isNull, !region.isInfinite, region.width >= 44, region.height >= 100 else { + throw NativeTextEntryIssue.unavailable + } + let top = CGPoint(x: region.midX, y: region.minY + region.height * 0.15) + let bottom = CGPoint(x: region.midX, y: region.maxY - region.height * 0.15) + let origin = window.coordinate(withNormalizedOffset: .zero) + func point(_ p: CGPoint) -> XCUICoordinate { + origin.withOffset(CGVector(dx: p.x - window.frame.minX, dy: p.y - window.frame.minY)) + } + point(towardTop ? top : bottom).press(forDuration: 0.05, thenDragTo: point(towardTop ? bottom : top)) + } + + private static func childRoomRegion(in app: XCUIApplication, owner: XCUIElement) throws -> CGRect { + let window = app.windows.firstMatch + guard owner.exists, window.exists else { throw NativeTextEntryIssue.unavailable } + var region = owner.frame.intersection(window.frame).insetBy(dx: 16, dy: 24) + guard [region.minX, region.minY, region.maxX, region.maxY].allSatisfy({ $0.isFinite }), + !region.isNull, !region.isInfinite + else { throw NativeTextEntryIssue.unavailable } + let initialRegion = region + let overlays = app.navigationBars.allElementsBoundByIndex + for overlay in overlays where overlay.exists { + let frame = overlay.frame + guard [frame.minX, frame.minY, frame.maxX, frame.maxY].allSatisfy({ $0.isFinite }) else { + throw NativeTextEntryIssue.unavailable + } + if frame.intersects(initialRegion), !frame.isEmpty { + let minY = max(region.minY, frame.maxY + 8) + region.size.height = max(0, region.maxY - minY) + region.origin.y = minY + } + } + let search = app.textFields["library.search"] + if search.exists { + let frame = search.frame + guard [frame.minX, frame.minY, frame.maxX, frame.maxY].allSatisfy({ $0.isFinite }) else { + throw NativeTextEntryIssue.unavailable + } + if frame.intersects(region), !frame.isEmpty { + region.size.height = max(0, frame.minY - region.minY - 8) + } + } + for keyboard in app.keyboards.allElementsBoundByIndex where keyboard.exists { + let frame = keyboard.frame + guard [frame.minX, frame.minY, frame.maxX, frame.maxY].allSatisfy({ $0.isFinite }) else { + throw NativeTextEntryIssue.unavailable + } + let overlap = frame.intersection(region) + if !overlap.isEmpty { region.size.height = max(0, overlap.minY - region.minY - 16) } + } + guard !region.isNull, !region.isInfinite, region.width >= 44, region.height >= 100 else { + throw NativeTextEntryIssue.unavailable + } + return region + } +} diff --git a/apps/maple-ios/UITests/NativeTextEntry.swift b/apps/maple-ios/UITests/NativeTextEntry.swift new file mode 100644 index 000000000..5fa1b9ba1 --- /dev/null +++ b/apps/maple-ios/UITests/NativeTextEntry.swift @@ -0,0 +1,195 @@ +import XCTest + +extension XCUIApplication { + /// Regular-width Chat still supplies its SwiftUI Menu button. Compact Chat + /// uses UIKit's actual Back control, scoped to the owned Chat bar so an + /// Auth or Settings Back button cannot satisfy the query in any locale. + @MainActor var mapleMenuButton: XCUIElement { + let custom = buttons["chat.menu"].firstMatch + if custom.exists { return custom } + return navigationBars["chat.navigation"].buttons["BackButton"].firstMatch + } + + @MainActor var mapleChatNavigationBar: XCUIElement { + let native = navigationBars["chat.navigation"].firstMatch + if native.exists { return native } + return navigationBars.containing(.button, identifier: "chat.menu").firstMatch + } +} + +/// A SwiftUI Form exposes the full toggle row as an accessibility switch. +/// Its center can be the label, so target the actual trailing native control. +@MainActor func tapNativeSwitch(_ row: XCUIElement, in app: XCUIApplication) { + XCTAssertTrue(row.isEnabled && row.isHittable) + let frame = row.frame + if (20...100).contains(frame.width), (20...100).contains(frame.height) { + row.tap(); return + } + let children = row.switches.allElementsBoundByIndex.filter { + $0.isHittable && (20...100).contains($0.frame.width) + && (20...100).contains($0.frame.height) && frame.contains($0.frame) + } + XCTAssertLessThanOrEqual(children.count, 1) + if let control = children.first { + control.tap() + } else { + XCTAssertGreaterThanOrEqual(frame.width, 160) + let point = CGPoint(x: frame.maxX - 48, y: frame.midY) + XCTAssertTrue(frame.contains(point) && app.windows.firstMatch.frame.contains(point)) + row.coordinate(withNormalizedOffset: CGVector(dx: 1, dy: 0.5)) + .withOffset(CGVector(dx: -48, dy: 0)).tap() + } +} + +enum NativeTextEntryIssue: Error { case unavailable, selectionUnavailable, clearFailed, replacementFailed } + +/// Only synthetic fixture text belongs here. Credentials use the separate private +/// paste path. Hardware Command-A is not an iPhone text-replacement operation. +@MainActor func replaceSyntheticText( + _ field: XCUIElement, with text: String, in app: XCUIApplication, + presentedAlert: XCUIElement? = nil, centeredNativeFocus: Bool = false, beforeTyping: (() throws -> Void)? = nil, + file: StaticString = #filePath, line: UInt = #line +) throws { + guard field.waitForExistence(timeout: 20), text.utf16.count <= 4_096, + let previous = field.value as? String, previous.utf16.count <= 4_096 + else { + XCTFail("Synthetic text field unavailable or outside fixture bounds", file: file, line: line) + throw NativeTextEntryIssue.unavailable + } + try interactWithSyntheticField( + field, in: app, presentedAlert: presentedAlert, longPress: false, centeredNativeFocus: centeredNativeFocus) + if !previous.isEmpty { + if let presentedAlert { + // Single-line alert fields may place a caret without opening an edit + // menu. Use native input only, then prove the complete field is empty. + try interactWithSyntheticField( + field, in: app, presentedAlert: presentedAlert, longPress: false, trailingInsertion: true) + field.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: previous.utf16.count)) + } else { + try interactWithSyntheticField(field, in: app, presentedAlert: presentedAlert, longPress: true) + let menu = app.menuItems["Select All"].firstMatch + let button = app.buttons["Select All"].firstMatch + if menu.waitForExistence(timeout: 2) { + try activateSyntheticSelection(menu, field: field, in: app, presentedAlert: presentedAlert) + } else if button.waitForExistence(timeout: 2) { + try activateSyntheticSelection(button, field: field, in: app, presentedAlert: presentedAlert) + } else { + XCTFail("Native Select All action unavailable; refusing to append fixture text", file: file, line: line) + throw NativeTextEntryIssue.selectionUnavailable + } + field.typeText(XCUIKeyboardKey.delete.rawValue) + } + let cleared = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + let value = field.value as? String + if value == "" { return true } + // An empty alert field can expose its placeholder as its AX value. + // Require the owning alert's disabled Save as independent evidence. + guard let presentedAlert, presentedAlert.exists, presentedAlert.elementType == .alert, + app.alerts.count == 1, presentedAlert.textFields.count == 1, + field.exists, field.elementType == .textField, + let placeholder = field.placeholderValue, !placeholder.isEmpty, value == placeholder + else { return false } + let ownedField = presentedAlert.textFields.element(boundBy: 0) + let saves = presentedAlert.buttons.matching(NSPredicate(format: "label == %@", "Save")) + let frame = field.frame + guard ownedField.exists, ownedField.identifier == field.identifier, + ownedField.frame == frame, frame.width > 0, frame.height > 0, + presentedAlert.frame.contains(frame), ownedField.placeholderValue == placeholder, + (ownedField.value as? String) == value, saves.count == 1 + else { return false } + let save = saves.element(boundBy: 0) + return save.exists && !save.isEnabled + }, object: field) + guard XCTWaiter.wait(for: [cleared], timeout: 5) == .completed else { + XCTFail("Native selection did not clear the complete synthetic field", file: file, line: line) + throw NativeTextEntryIssue.clearFailed + } + } + try beforeTyping?() + if !text.isEmpty { field.typeText(text) } + let replaced = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in (field.value as? String) == text }, object: field) + guard XCTWaiter.wait(for: [replaced], timeout: 10) == .completed else { + XCTFail("Synthetic replacement did not match exactly; refusing to submit", file: file, line: line) + throw NativeTextEntryIssue.replacementFailed + } +} + +/// Alert-owned field events stay anchored to the expected alert. Like the edit +/// menu action below, a direct element event may otherwise trigger XCTest's +/// interruption path before UIKit receives the intended field gesture. +@MainActor private func interactWithSyntheticField( + _ field: XCUIElement, in app: XCUIApplication, presentedAlert: XCUIElement?, longPress: Bool, + trailingInsertion: Bool = false, centeredNativeFocus: Bool = false +) throws { + guard let presentedAlert else { + if longPress { + field.press(forDuration: 1.2) + } else if centeredNativeFocus { + guard field.exists, field.isEnabled, field.isHittable else { throw NativeTextEntryIssue.unavailable } + let fieldFrame = field.frame, appFrame = app.frame + guard + [fieldFrame, appFrame].allSatisfy({ + $0.minX.isFinite && $0.minY.isFinite && $0.maxX.isFinite && $0.maxY.isFinite + && $0.width.isFinite && $0.height.isFinite && $0.width > 0 && $0.height > 0 + }), appFrame.contains(fieldFrame) + else { throw NativeTextEntryIssue.unavailable } + // One ordinary event inside the observed editor, without a second focus tap. + app.coordinate(withNormalizedOffset: .zero).withOffset( + CGVector(dx: fieldFrame.midX - appFrame.minX, dy: fieldFrame.midY - appFrame.minY) + ).tap() + } else { + field.tap() + } + return + } + guard presentedAlert.exists, presentedAlert.elementType == .alert, + app.alerts.count == 1, presentedAlert.textFields.count == 1, + field.exists, field.elementType == .textField, field.isEnabled, field.isHittable + else { throw NativeTextEntryIssue.selectionUnavailable } + let ownedField = presentedAlert.textFields.element(boundBy: 0) + let ownerFrame = presentedAlert.frame, fieldFrame = field.frame, appFrame = app.frame + let frames = [ownerFrame, fieldFrame, appFrame] + guard ownedField.exists, ownedField.identifier == field.identifier, ownedField.frame == fieldFrame, + frames.allSatisfy({ + $0.minX.isFinite && $0.minY.isFinite && $0.width.isFinite && $0.height.isFinite + && $0.width > 0 && $0.height > 0 + }), ownerFrame.contains(fieldFrame), appFrame.contains(ownerFrame) + else { throw NativeTextEntryIssue.selectionUnavailable } + // The trailing point is inside the observed field. Scrolled or RTL text can + // still leave a suffix; the unchanged empty-value gate must reject that. + let insertionX = trailingInsertion ? fieldFrame.maxX - min(8, fieldFrame.width / 4) : fieldFrame.midX + let coordinate = presentedAlert.coordinate(withNormalizedOffset: .zero).withOffset( + CGVector(dx: insertionX - ownerFrame.minX, dy: fieldFrame.midY - ownerFrame.minY)) + if longPress { coordinate.press(forDuration: 1.2) } else { coordinate.tap() } + guard presentedAlert.exists, field.exists else { throw NativeTextEntryIssue.selectionUnavailable } +} + +/// An edit menu appears outside its alert's accessibility subtree. Element.tap() +/// treats that expected alert as an interruption and dismisses it. Anchor the +/// native event to the observed owning alert and the current action frame. +@MainActor private func activateSyntheticSelection( + _ action: XCUIElement, field: XCUIElement, in app: XCUIApplication, presentedAlert: XCUIElement? +) throws { + guard let presentedAlert else { + action.tap() + return + } + guard presentedAlert.exists, app.alerts.count == 1, presentedAlert.textFields.count == 1, + field.exists, action.exists, action.isEnabled, action.isHittable + else { throw NativeTextEntryIssue.selectionUnavailable } + let ownerFrame = presentedAlert.frame, fieldFrame = field.frame, actionFrame = action.frame + let appFrame = app.frame + let frames = [ownerFrame, fieldFrame, actionFrame, appFrame] + guard + frames.allSatisfy({ + $0.minX.isFinite && $0.minY.isFinite && $0.width.isFinite && $0.height.isFinite + && $0.width > 0 && $0.height > 0 + }), ownerFrame.contains(fieldFrame), appFrame.contains(actionFrame) + else { throw NativeTextEntryIssue.selectionUnavailable } + presentedAlert.coordinate(withNormalizedOffset: .zero).withOffset( + CGVector(dx: actionFrame.midX - ownerFrame.minX, dy: actionFrame.midY - ownerFrame.minY) + ).tap() + guard presentedAlert.exists, field.exists else { throw NativeTextEntryIssue.selectionUnavailable } +} diff --git a/apps/maple-ios/UITests/PresentationCoverUITests.swift b/apps/maple-ios/UITests/PresentationCoverUITests.swift new file mode 100644 index 000000000..e0cd15f21 --- /dev/null +++ b/apps/maple-ios/UITests/PresentationCoverUITests.swift @@ -0,0 +1,121 @@ +import XCTest + +@MainActor final class PresentationCoverUITests: XCTestCase { + func testImageCoverBlocksCompletionEligibilityUntilDismissal() { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["--maple-renderer-fixture", "--maple-presentation-cover-fixture"] + app.launch() + let summary = app.staticTexts["cover.summary"] + XCTAssertTrue(summary.waitForExistence(timeout: 15)) + XCTAssertEqual(summary.label, "Leases 0; completions 0; covered 0; plays 0; haptics 0; pending 0; failed 0") + let image = app.buttons["cover.open"] + XCTAssertTrue(image.waitForExistence(timeout: 10)) + image.tap() + let close = app.buttons["images.close"] + XCTAssertTrue(close.waitForExistence(timeout: 10)) + let inside = app.staticTexts["cover.inside-summary"] + XCTAssertTrue(inside.waitForExistence(timeout: 10)) + XCTAssertEqual(inside.label, "Leases 1; completions 0; covered 0; plays 0; haptics 0; pending 0; failed 0") + // Trigger only after the actual viewer and its control are accessible. + // A presenter-onDisappear lease release therefore cannot pass this test. + app.buttons["cover.covered-completion"].tap() + let covered = XCTNSPredicateExpectation( + predicate: NSPredicate( + format: "label == %@", "Leases 1; completions 1; covered 1; plays 0; haptics 0; pending 0; failed 0"), + object: inside) + XCTAssertEqual(XCTWaiter.wait(for: [covered], timeout: 10), .completed) + XCTAssertTrue(close.exists) + close.tap() + let blocked = "Leases 0; completions 1; covered 1; plays 0; haptics 0; pending 0; failed 0" + let dismissed = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "label == %@", blocked), object: summary) + XCTAssertEqual(XCTWaiter.wait(for: [dismissed], timeout: 10), .completed) + // Changing visibility must not replay a completion missed while covered. + let catchup = expectation(description: "Observe no visibility catch-up") + XCTAssertEqual(XCTWaiter.wait(for: [catchup], timeout: 1), .timedOut) + XCTAssertEqual(summary.label, blocked) + app.buttons["cover.visible-completion"].tap() + let eligible = XCTNSPredicateExpectation( + predicate: NSPredicate( + format: "label == %@", "Leases 0; completions 2; covered 1; plays 1; haptics 1; pending 0; failed 0"), + object: summary) + XCTAssertEqual(XCTWaiter.wait(for: [eligible], timeout: 10), .completed) + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Image cover suppresses completion and visible completion restores eligibility" + attachment.lifetime = .keepAlways + add(attachment) + app.terminate() + } + + func testImageCoverDismissalRestoresDirectNativeMenu() { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = [ + "--maple-renderer-fixture", "--maple-presentation-cover-fixture", + "--maple-presentation-cover-navigation-proof", + ] + app.launch() + defer { app.terminate() } + let summary = app.staticTexts["cover.summary"] + XCTAssertTrue(summary.waitForExistence(timeout: 15)) + XCTAssertEqual(summary.label, "Leases 0; completions 0; covered 0; plays 0; haptics 0; pending 0; failed 0") + let initialMenu = app.mapleMenuButton + XCTAssertTrue(initialMenu.waitForExistence(timeout: 10)) + XCTAssertTrue(initialMenu.isHittable) + + app.buttons["cover.open"].tap() + let closeImage = app.buttons["images.close"] + XCTAssertTrue(closeImage.waitForExistence(timeout: 10)) + let inside = app.staticTexts["cover.inside-summary"] + XCTAssertTrue(inside.waitForExistence(timeout: 10)) + XCTAssertEqual(inside.label, "Leases 1; completions 0; covered 0; plays 0; haptics 0; pending 0; failed 0") + app.buttons["cover.covered-completion"].tap() + let covered = XCTNSPredicateExpectation( + predicate: NSPredicate( + format: "label == %@", "Leases 1; completions 1; covered 1; plays 0; haptics 0; pending 0; failed 0"), + object: inside) + XCTAssertEqual(XCTWaiter.wait(for: [covered], timeout: 10), .completed) + closeImage.tap() + let blocked = "Leases 0; completions 1; covered 1; plays 0; haptics 0; pending 0; failed 0" + let dismissed = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "label == %@", blocked), object: summary) + XCTAssertEqual(XCTWaiter.wait(for: [dismissed], timeout: 10), .completed) + + // Tap the real native bar immediately after dismissal. No edge gesture, + // response, remount or fixture control can repair it before this check. + let menu = app.mapleMenuButton + XCTAssertTrue(menu.waitForExistence(timeout: 10)) + XCTAssertTrue(menu.isHittable) + menu.tap() + let library = app.staticTexts["cover.library"] + let closeMenu = app.buttons["cover.library-close"] + XCTAssertTrue(library.waitForExistence(timeout: 10)) + let menuReady = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "exists == true AND enabled == true AND hittable == true"), + object: closeMenu) + XCTAssertEqual(XCTWaiter.wait(for: [menuReady], timeout: 10), .completed) + XCTAssertTrue(library.isHittable) + closeMenu.tap() + let returned = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "exists == false"), object: closeMenu) + XCTAssertEqual(XCTWaiter.wait(for: [returned], timeout: 10), .completed) + XCTAssertTrue(summary.waitForExistence(timeout: 10)) + XCTAssertTrue(summary.isHittable) + XCTAssertEqual(summary.label, blocked) + + let catchup = expectation(description: "Observe no completion catch-up after native navigation") + XCTAssertEqual(XCTWaiter.wait(for: [catchup], timeout: 1), .timedOut) + XCTAssertEqual(summary.label, blocked) + app.buttons["cover.visible-completion"].tap() + let eligible = XCTNSPredicateExpectation( + predicate: NSPredicate( + format: "label == %@", "Leases 0; completions 2; covered 1; plays 1; haptics 1; pending 0; failed 0"), + object: summary) + XCTAssertEqual(XCTWaiter.wait(for: [eligible], timeout: 10), .completed) + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Image cover dismissal restores direct native Menu and Close" + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/apps/maple-ios/UITests/ReferenceComparisonUITests.swift b/apps/maple-ios/UITests/ReferenceComparisonUITests.swift new file mode 100644 index 000000000..5f1b7332c --- /dev/null +++ b/apps/maple-ios/UITests/ReferenceComparisonUITests.swift @@ -0,0 +1,1722 @@ +import Foundation +import UIKit +import XCTest + +/// Captures ordinary local UI for side-by-side reference review. This has no +/// product fixture launch flag and never seeds or fabricates a conversation. +/// Screenshot data must be synthetic content belonging to the dedicated account. +@MainActor final class ReferenceComparisonUITests: XCTestCase { + private struct CaptureInput: Decodable { + let account: String + var orientation: String + var appearance: String + let deviceClass: String + let conversationID: String? + let imageConversationID: String? + let projectID: String? + let groups: [CaptureGroup]? + } + private struct CaptureGroup: Decodable { + let group: String + let orientation: String + let appearance: String + let budgetSeconds: Int + } + private struct Credentials: Decodable { let email: String; let password: String } + private var app: XCUIApplication! + private var input: CaptureInput! + private var activeGroup: CaptureGroup? + private var batchAuthenticated = false + private var verifiedCell: String? + private var observedAppearance: String? + private var observedWebPreference: String? + private var accountVerified = false + private var menuSelectionsVerified = false + private var sidebarSelectionWitness: (conversationID: String, messageID: String)? + private var composer: XCUIElement { app.textViews["chat.composer"] } + + private func readPrivate(_ name: String) throws -> T { + let support = try FileManager.default.url( + for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + let path = support.appendingPathComponent(name) + let attributes = try FileManager.default.attributesOfItem(atPath: path.path) + guard attributes[.type] as? FileAttributeType == .typeRegular, + let size = attributes[.size] as? NSNumber, size.intValue <= 8_192 + else { throw ReferenceCaptureIssue.invalidInput } + let value = try JSONDecoder().decode(T.self, from: Data(contentsOf: path)) + try FileManager.default.removeItem(at: path) + return value + } + + private func loadInput() throws { + if input != nil { return } + try XCTSkipUnless(ProcessInfo.processInfo.environment["MAPLE_RUN_REFERENCE_UI"] == "1") + continueAfterFailure = false + input = try readPrivate("MapleReferenceCapture.json") + guard ["free", "pro"].contains(input.account), ["portrait", "landscape"].contains(input.orientation), + ["light", "dark"].contains(input.appearance), ["phone", "tablet"].contains(input.deviceClass) + else { throw ReferenceCaptureIssue.invalidInput } + } + + private func start(signedIn: Bool) throws { + try loadInput() + if let activeGroup { + input.orientation = activeGroup.orientation; input.appearance = activeGroup.appearance + } + // Authentication is setup for signed-in reference surfaces. Keep that + // form in portrait, then restore the admitted capture cell before Settings. + let launchOrientation = signedIn && !batchAuthenticated ? "portrait" : input.orientation + XCUIDevice.shared.orientation = launchOrientation == "portrait" ? .portrait : .landscapeLeft + app = XCUIApplication() + app.launchArguments = ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + app.launch() + let available = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.app.textFields["auth.identifier"].exists || self.app.buttons["chat.new"].exists + }, object: app) + XCTAssertEqual(XCTWaiter.wait(for: [available], timeout: 45), .completed) + if batchAuthenticated { + // Never silently reauthenticate, cross accounts, or reuse an anonymous + // launch after an earlier group retired the admitted account. + XCTAssertTrue(signedIn); XCTAssertTrue(app.buttons["chat.new"].exists) + XCTAssertFalse(app.textFields["auth.identifier"].exists) + try alignCellIfNeeded() + } else { + if app.buttons["chat.new"].exists { + try openSettings(); tap("settings.sign-out") + } + XCTAssertTrue(app.textFields["auth.identifier"].waitForExistence(timeout: 30)) + if signedIn { + let credentials: Credentials = try readPrivate("MapleUITestCredentials.json") + defer { UIPasteboard.general.items = [] } + try pasteCredential(credentials.email, field: app.textFields["auth.identifier"]) + try pasteCredential(credentials.password, field: app.secureTextFields["auth.password"]) + UIPasteboard.general.items = [] + tap("auth.submit", in: app.scrollViews.containing(.button, identifier: "auth.submit").firstMatch) + XCTAssertTrue(app.buttons["chat.new"].waitForExistence(timeout: 45)) + XCTAssertFalse(app.staticTexts["auth.error"].exists) + XCUIDevice.shared.orientation = input.orientation == "portrait" ? .portrait : .landscapeLeft + try alignPreferences(expectedEmail: credentials.email) + batchAuthenticated = activeGroup != nil + } + } + assertFullWindow() + } + + private func assertFullWindow() { + let short: CGFloat = input.deviceClass == "tablet" ? 834 : 402 + let long: CGFloat = input.deviceClass == "tablet" ? 1_210 : 874 + let expected = + input.orientation == "portrait" + ? CGSize(width: short, height: long) : CGSize(width: long, height: short) + let oriented = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + abs(self.app.frame.width - expected.width) <= 1 + && abs(self.app.frame.height - expected.height) <= 1 + && abs(self.app.frame.minX) <= 1 && abs(self.app.frame.minY) <= 1 + }, object: app) + XCTAssertEqual(XCTWaiter.wait(for: [oriented], timeout: 15), .completed) + } + + /// A completed group receipt records reached UI steps only. The private + /// runner accepts no group as passed unless the complete XCTest also passes. + private func groupReceipt(_ phase: String, index: Int, elapsed: TimeInterval? = nil) throws { + let group = try XCTUnwrap(activeGroup) + var record: [String: Any] = [ + "kind": "reference-group", "phase": phase, "index": index, + "key": [input.account, input.deviceClass, group.orientation, group.appearance, group.group].joined( + separator: "."), + "scope": "Reached steps; acceptance requires authoritative XCTest success", + ] + if let elapsed { record["elapsed_seconds"] = elapsed } + let data = try JSONSerialization.data(withJSONObject: record, options: .sortedKeys) + let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.json") + attachment.name = "reference-group.\(index).\(phase)" + attachment.lifetime = .keepAlways; add(attachment) + } + + func testCaptureReferenceBatch() throws { + try loadInput() + let groups = try XCTUnwrap(input.groups) + guard !groups.isEmpty, groups.count <= 16 else { throw ReferenceCaptureIssue.invalidInput } + var keys = Set() + for group in groups { + guard ["portrait", "landscape"].contains(group.orientation), ["light", "dark"].contains(group.appearance), + (90...300).contains(group.budgetSeconds), group.group != "authentication", + keys.insert([group.orientation, group.appearance, group.group].joined(separator: ".")).inserted + else { throw ReferenceCaptureIssue.invalidInput } + } + for (index, group) in groups.enumerated() { + activeGroup = group + try groupReceipt("started", index: index) + let began = Date() + switch group.group { + case "new-chat": try testCaptureNewChatAndKeyboard() + case "corpus": try testCaptureSharedRichConversation() + case "reasoning": try testCaptureSharedReasoningDisclosure() + case "edge-navigation": try testLeadingEdgeMenuAndClosePreserveDraftAndTranscriptPosition() + case "images": try testCaptureSharedImageConversation() + case "library": try testCaptureLibraryAndHistory() + case "project": try testCaptureSharedProjectAndInstructions() + case "access": try testCaptureReadOnlyModelsAndFeatureAccess() + case "settings": try testCaptureReadOnlyAccountAndPreferences() + case "security": try testCaptureSecurityAndDeletionWarnings() + case "player": try testCaptureManualReplyPlayer() + default: throw ReferenceCaptureIssue.invalidInput + } + // Initial real sign-in and each new cell's Settings visit have + // separate finite allowances; blocking XCTest itself has a batch cap. + let previous = index > 0 ? groups[index - 1] : nil + let changedCell = previous?.orientation != group.orientation || previous?.appearance != group.appearance + let allowance = group.budgetSeconds + (index == 0 ? 420 : (changedCell ? 180 : 0)) + let elapsed = Date().timeIntervalSince(began) + XCTAssertLessThanOrEqual(elapsed, Double(allowance)) + try groupReceipt("completed", index: index, elapsed: elapsed) + } + // The phone Pro schedule ends in portrait/light. Its real account stays + // signed in for the already-planned ordinary-root cases that follow. + } + + private func pasteCredential(_ value: String, field: XCUIElement) throws { + guard field.waitForExistence(timeout: 15) else { throw ReferenceCaptureIssue.missingControl } + let identifier = field.identifier + UIPasteboard.general.setItems( + [["public.utf8-plain-text": value]], + options: [.localOnly: true, .expirationDate: Date().addingTimeInterval(30)]) + captureCredentialPasteState("published", value: value, field: field, identifier: identifier) + field.tap() + field.press(forDuration: 1.2) + let menu = app.menuItems["Paste"].firstMatch, button = app.buttons["Paste"].firstMatch + if menu.waitForExistence(timeout: 3) { + captureCredentialPasteState( + "before-paste", value: value, field: field, identifier: identifier, paste: menu) + menu.tap() + captureCredentialPasteState( + "after-paste", value: value, field: field, identifier: identifier, paste: menu) + } else if button.waitForExistence(timeout: 3) { + captureCredentialPasteState( + "before-paste", value: value, field: field, identifier: identifier, paste: button) + button.tap() + captureCredentialPasteState( + "after-paste", value: value, field: field, identifier: identifier, paste: button) + } else if identifier == "auth.identifier" || identifier == "auth.password" { + // Only use the public paste command when neither edit-menu action + // is available. Never redeliver after a failed commitment assertion. + captureCredentialPasteState("before-paste", value: value, field: field, identifier: identifier) + field.typeKey("v", modifierFlags: .command) + captureCredentialPasteState("after-paste", value: value, field: field, identifier: identifier) + } else { + throw ReferenceCaptureIssue.missingControl + } + // Keep the private clipboard item until this real form reports committed input. + // The predicate exposes no credential value in its assertion or diagnostics. + guard field.identifier == "auth.identifier" || field.identifier == "auth.password" else { return } + let committed = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + if field.identifier == "auth.identifier" { + return field.value as? String == value + } + let submit = self.app.buttons["auth.submit"].firstMatch + return submit.exists && submit.isEnabled + }, object: app) + XCTAssertEqual(XCTWaiter.wait(for: [committed], timeout: 10), .completed) + } + private func captureCredentialPasteState( + _ phase: String, value: String, field: XCUIElement, identifier: String, paste: XCUIElement? = nil + ) { + guard identifier == "auth.identifier" || identifier == "auth.password" else { return } + let pasteboard = UIPasteboard.general + var record: [String: Any] = [ + "phase": phase, + "clipboard": [ + "hasStrings": pasteboard.hasStrings, + "items": pasteboard.numberOfItems, + "changeCount": pasteboard.changeCount, + ], + ] + let fieldExists = field.exists + let fieldValue = fieldExists ? field.value : nil + record["field"] = [ + "identifier": identifier, + "exists": fieldExists, + "hittable": fieldExists && field.isHittable, + "valueIsString": fieldValue is String, + "fixtureMatches": fieldValue as? String == value, + ] + if let paste { + let pasteExists = paste.exists + record["paste"] = [ + "exists": pasteExists, + "hittable": pasteExists && paste.isHittable, + "enabled": pasteExists && paste.isEnabled, + ] + } + guard let data = try? JSONSerialization.data(withJSONObject: record, options: .sortedKeys) else { return } + let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.json") + attachment.name = "credential-paste.\(identifier).\(phase)" + attachment.lifetime = .keepAlways; add(attachment) + } + private func pasteSynthetic(_ value: String) throws { + try replaceSyntheticText(composer, with: "", in: app) + defer { UIPasteboard.general.items = [] } + try pasteCredential(value, field: composer) + let matches = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + Array((self.composer.value as? String ?? "").utf8) == Array(value.utf8) + }, object: composer) + XCTAssertEqual(XCTWaiter.wait(for: [matches], timeout: 10), .completed) + } + private func visibleRegion(of element: XCUIElement) -> CGRect { + guard element.exists else { return .null } + return element.frame.intersection(app.frame) + } + private func hasVisibleContent(_ collection: XCUIElement, prefix: String? = nil) -> Bool { + let region = visibleRegion(of: collection) + guard region.width >= 100, region.height >= 100 else { return false } + let descendants = collection.descendants(matching: .any) + let candidates = + prefix.map { + descendants.matching(NSPredicate(format: "identifier BEGINSWITH %@", $0)) + } ?? descendants + return candidates.allElementsBoundByIndex.contains { + $0.exists && $0.isHittable && !$0.frame.intersection(region).isEmpty + } + } + private func visibleCollection() -> XCUIElement? { + app.collectionViews.allElementsBoundByIndex.first { hasVisibleContent($0) } + } + private func visibleLibrary() -> XCUIElement? { + let sidebar = app.collectionViews["library.sidebar-list"] + return hasVisibleContent(sidebar, prefix: "library.") ? sidebar : nil + } + private func keyboardIsVisible() -> Bool { + let buttons = app.keyboards.buttons.allElementsBoundByIndex + guard buttons.count <= 160 else { + XCTFail("Keyboard control inventory exceeds the bounded visibility check") + return true + } + let visibleKey = buttons.contains { + $0.exists && $0.isHittable && !$0.frame.intersection(app.frame).isEmpty + } + let hide = app.buttons["Hide keyboard"].firstMatch + return visibleKey || (hide.exists && hide.isHittable) + } + private func composerKeyboardGeometry() -> (clear: Bool, frames: [CGRect], scalars: [String: Double]) { + let bounds = app.frame, editor = composer, send = app.buttons["chat.send"].firstMatch + let buttons = app.keyboards.buttons.allElementsBoundByIndex + if buttons.count > 160 { + XCTFail("Keyboard control inventory exceeds the bounded visibility check") + } + let keyboardVisible = + buttons.count <= 160 + && buttons.contains { + $0.exists && $0.isHittable && !$0.frame.intersection(bounds).isEmpty + } + let keyboardCount = app.keyboards.count, keyboardElement = app.keyboards.firstMatch + let keyboard = + keyboardVisible && keyboardCount == 1 && keyboardElement.exists + ? keyboardElement.frame.intersection(bounds) : .null + let editorExists = editor.exists, sendExists = send.exists + let editorHittable = editorExists && editor.isHittable, sendHittable = sendExists && send.isHittable + let input = editorExists ? editor.frame : .null, action = sendExists ? send.frame : .null + let frames = [bounds, input, action, keyboard] + let finite = frames.allSatisfy { + [$0.minX, $0.minY, $0.width, $0.height].allSatisfy { $0.isFinite } + && $0.width > 0 && $0.height > 0 + } + let targetsHittable = editorHittable && sendHittable + let minimumSize = input.width >= 44 && input.height >= 44 && action.width >= 44 && action.height >= 44 + let contained = finite && bounds.contains(input) && bounds.contains(action) + let aboveKeyboard = finite && input.maxY <= keyboard.minY && action.maxY <= keyboard.minY + let clear = + keyboardVisible && keyboardCount == 1 && finite + && targetsHittable && minimumSize && contained && aboveKeyboard + var scalars: [String: Double] = [ + "keyboardVisible": keyboardVisible ? 1 : 0, "keyboardButtonCount": Double(buttons.count), + "keyboardContainerCount": Double(keyboardCount), "finiteFrames": finite ? 1 : 0, + "composerExists": editorExists ? 1 : 0, "composerHittable": editorHittable ? 1 : 0, + "sendExists": sendExists ? 1 : 0, "sendHittable": sendHittable ? 1 : 0, + "minimum44Targets": minimumSize ? 1 : 0, "containedInWindow": contained ? 1 : 0, + "aboveKeyboard": aboveKeyboard ? 1 : 0, "clear": clear ? 1 : 0, + "composerClearance": Double(keyboard.minY - input.maxY), + "sendClearance": Double(keyboard.minY - action.maxY), + ] + for (name, frame) in zip(["window", "composer", "send", "keyboard"], frames) { + scalars[name + "X"] = Double(frame.minX); scalars[name + "Y"] = Double(frame.minY) + scalars[name + "Width"] = Double(frame.width); scalars[name + "Height"] = Double(frame.height) + } + return (clear, frames, scalars.mapValues { $0.isFinite ? $0 : -Double.greatestFiniteMagnitude }) + } + private func sameKeyboardGeometry(_ previous: [CGRect], _ current: [CGRect]) -> Bool { + guard previous.count == current.count else { return false } + return zip(previous, current).allSatisfy { before, after in + [ + before.minX - after.minX, before.minY - after.minY, before.width - after.width, + before.height - after.height, + ] + .allSatisfy { abs($0) <= 0.5 } + } + } + private func dismissTabletKeyboard() { + guard input.deviceClass == "tablet", keyboardIsVisible() else { return } + // This public system control was observed on the owned iPad. Its keyboard + // AX container can remain after dismissal, so require absent visible keys. + let hide = app.buttons["Hide keyboard"].firstMatch + XCTAssertTrue(hide.waitForExistence(timeout: 5)); XCTAssertTrue(hide.isHittable) + hide.tap() + let hidden = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in !self.keyboardIsVisible() }, object: app) + XCTAssertEqual(XCTWaiter.wait(for: [hidden], timeout: 10), .completed) + } + private func scroll( + _ directionDown: Bool, in explicit: XCUIElement? = nil, toward target: XCUIElement? = nil + ) { + // Keep the same list owner throughout a reveal. A visible iPad sidebar + // can report isHittable=false on its container after the first swipe. + // Never redirect a Library/Form gesture into the adjacent transcript. + let container = explicit ?? visibleCollection() + guard let container, hasVisibleContent(container) else { + XCTFail("The owning list has no visible interactive content") + return + } + var region = visibleRegion(of: container).insetBy(dx: 16, dy: 24) + if keyboardIsVisible() { + for keyboard in app.keyboards.allElementsBoundByIndex where keyboard.exists { + let overlap = keyboard.frame.intersection(region) + if !overlap.isEmpty { region.size.height = max(0, overlap.minY - region.minY - 16) } + } + } + guard region.width >= 44, region.height >= 100 else { + XCTFail("The owning list has no unobstructed scroll region") + return + } + // Reveal takes shorter steps and brings a known clipped target toward + // the visible region's center. Direct capture scrolling keeps its span. + let inset: CGFloat = target == nil ? 0.15 : 0.325 + var dragDown = directionDown + if let target, target.exists { + let frame = target.frame + if !frame.isEmpty && !frame.isNull && !frame.isInfinite && frame.midY.isFinite { + dragDown = frame.midY < region.midY + } + } + let top = CGPoint(x: region.midX, y: region.minY + region.height * inset) + let bottom = CGPoint(x: region.midX, y: region.maxY - region.height * inset) + let origin = app.coordinate(withNormalizedOffset: .zero) + func coordinate(_ point: CGPoint) -> XCUICoordinate { + origin.withOffset(CGVector(dx: point.x - app.frame.minX, dy: point.y - app.frame.minY)) + } + coordinate(dragDown ? top : bottom).press( + forDuration: 0.05, thenDragTo: coordinate(dragDown ? bottom : top)) + } + private func reveal( + _ element: XCUIElement, down: Bool = false, attempts: Int = 8, in explicit: XCUIElement? = nil + ) { + var owner = explicit + for _ in 0.. XCUIElement? { + let candidates = commands.allElementsBoundByIndex + guard candidates.count <= 8 else { return nil } + let visible = candidates.filter { + guard $0.exists, $0.isEnabled, $0.isHittable else { return false } + let frame = $0.frame + return [frame.minX, frame.minY, frame.width, frame.height].allSatisfy { $0.isFinite } + && frame.width > 0 && frame.height > 0 && app.frame.contains(frame) + } + return visible.count == 1 ? visible[0] : nil + } + let ready = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in visibleCommand() != nil }, object: app) + let result = XCTWaiter.wait(for: [ready], timeout: 5) + let command = visibleCommand() + let record = XCTAttachment( + string: + "ready=\(result == .completed); candidates=\(min(commands.count, 9)); role=\(command?.elementType.rawValue ?? 0)" + ) + record.name = "Library selection menu command"; record.lifetime = .keepAlways; add(record) + XCTAssertEqual(result, .completed); XCTAssertLessThanOrEqual(commands.count, 8) + guard result == .completed, let command else { throw ReferenceCaptureIssue.missingControl } + command.tap() + } + @discardableResult private func openLibrary() throws -> XCUIElement { + dismissTabletKeyboard() + if let library = visibleLibrary() { return library } + tap("chat.menu") + let visible = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in self.visibleLibrary() != nil }, object: app) + XCTAssertEqual(XCTWaiter.wait(for: [visible], timeout: 20), .completed) + return try XCTUnwrap(visibleLibrary()) + } + private func openSettings() throws { + _ = try openLibrary() + // Settings is an explicit action in the principal Maple menu. + tap("library.actions", attempts: 24, in: app) + tap("library.settings", attempts: 24, in: app) + XCTAssertTrue(app.navigationBars["Settings"].waitForExistence(timeout: 20)) + } + private func dismissChatOptions() throws { + let pickers = app.collectionViews.matching(identifier: "settings.models") + let picker = pickers.firstMatch + XCTAssertTrue(picker.waitForExistence(timeout: 10)) + XCTAssertEqual(pickers.count, 1) + let window = app.windows.firstMatch.frame + let sheet = picker.frame + let outsideHeight = sheet.minY - window.minY + let point = CGPoint(x: window.midX, y: window.minY + outsideHeight / 2) + guard pickers.count == 1, picker.exists, + [window, sheet].allSatisfy({ + [$0.minX, $0.minY, $0.width, $0.height].allSatisfy { $0.isFinite } + && $0.width > 0 && $0.height > 0 + }), outsideHeight > 44, window.contains(point), !sheet.contains(point) + else { throw ReferenceCaptureIssue.missingControl } + app.coordinate(withNormalizedOffset: .zero).withOffset( + CGVector(dx: point.x - app.frame.minX, dy: point.y - app.frame.minY) + ).tap() + XCTAssertTrue(picker.waitForNonExistence(timeout: 10)) + } + + private func returnFromModelPicker() { + XCTAssertTrue(app.collectionViews["settings.models"].exists) + let back = app.navigationBars.buttons.matching( + NSPredicate(format: "label == %@ OR label == %@", "Settings", "Back") + ).firstMatch + XCTAssertTrue(back.exists); XCTAssertTrue(back.isHittable); back.tap() + } + private func closeSettings() { + if app.buttons["library.settings-close"].exists { + tap("library.settings-close") + } else { + let back = app.navigationBars["Settings"].buttons.firstMatch + XCTAssertTrue(back.isHittable); back.tap() + } + if app.buttons["library.close"].exists { tap("library.close") } + } + private var cellKey: String { "\(input.orientation).\(input.appearance)" } + + private func verifyPlan() { + let plan = app.staticTexts["settings.plan-name"] + XCTAssertTrue(plan.waitForExistence(timeout: 20)) + reveal(plan, down: true) + XCTAssertEqual(plan.label, input.account == "pro" ? "Pro Plan" : "Free Plan") + } + + private func selectAndVerifyAppearance() { + tap("settings.theme"); tap(input.appearance == "light" ? "Light" : "Dark") + let expected = input.appearance == "light" ? "Light" : "Dark" + let control = app.buttons["settings.theme"] + let selected = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + control.value as? String == expected || control.label == "Appearance, " + expected + }, object: control) + XCTAssertEqual(XCTWaiter.wait(for: [selected], timeout: 10), .completed) + observedAppearance = input.appearance + } + + private func verifyWebPreference() { + let web = app.switches["settings.web-search"] + reveal(web) + if web.isEnabled && web.value as? String == "1" { turnWebPreferenceOff(web) } + let value = web.value as? String + XCTAssertTrue(web.isEnabled); XCTAssertEqual(value, "0") + observedWebPreference = value + } + + private func turnWebPreferenceOff(_ web: XCUIElement) { + guard let tap = tapWebPreference(web) else { return } + let off = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in web.exists && web.isEnabled && web.value as? String == "0" }, + object: web) + let result = XCTWaiter.wait(for: [off], timeout: 5) + recordWebTarget( + "after", web: web, window: app.windows.firstMatch.frame, point: tap.point, strategy: tap.strategy) + XCTAssertEqual(result, .completed) + } + + @discardableResult + private func tapWebPreference(_ web: XCUIElement) -> (point: CGPoint, strategy: String)? { + XCTAssertTrue(web.isEnabled); XCTAssertTrue(web.isHittable) + let window = app.windows.firstMatch.frame + let row = web.frame + guard row.width >= 160, row.height >= 28, row.height <= 120, + [row.minX, row.minY, row.width, row.height].allSatisfy({ $0.isFinite }) + else { XCTFail("The Web search control has unexpected geometry"); return nil } + let nativeSwitches = web.switches.allElementsBoundByIndex.filter { + $0.exists && $0.isHittable && $0.frame.width >= 20 && $0.frame.width <= 100 + && $0.frame.height >= 20 && $0.frame.height <= 100 && row.contains($0.frame) + } + guard nativeSwitches.count <= 1 else { + XCTFail("The Web search control has ambiguous native switches"); return nil + } + let target: XCUICoordinate + let point: CGPoint + let strategy: String + if let native = nativeSwitches.first { + let frame = native.frame + point = CGPoint(x: frame.midX, y: frame.midY) + target = native.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + strategy = "unique-native-switch" + } else { + // This reference suite launches English/LTR. On the observed wide + // Settings and picker rows, XCTest's default center tap misses the trailing + // switch. Derive one control-region point from its fresh frame. + point = CGPoint(x: row.maxX - 48, y: row.midY) + target = web.coordinate(withNormalizedOffset: CGVector(dx: 1, dy: 0.5)) + .withOffset(CGVector(dx: -48, dy: 0)) + strategy = "observed-trailing-region" + } + guard window.contains(point), row.contains(point) else { + XCTFail("The Web search target is outside its current window or row"); return nil + } + recordWebTarget("before", web: web, window: window, point: point, strategy: strategy) + target.tap() + // Exactly one tap; no second-point fallback, setting injection, or retry. + return (point, strategy) + } + + private func recordWebTarget( + _ phase: String, web: XCUIElement, window: CGRect, point: CGPoint, strategy: String + ) { + let record = XCTAttachment( + string: "strategy=\(strategy); window=\(window); row=\(web.frame); " + + "point=(\(point.x),\(point.y)); enabled=\(web.isEnabled); value=\(web.value as? String ?? "nil")") + record.name = "reference.web-preference." + phase + record.lifetime = .keepAlways; add(record) + } + + private func alignPreferences(expectedEmail: String) throws { + try openSettings() + // Compare in memory without putting the expected email in an XCUI query, + // assertion message, receipt or command argument. + let sameAccount = app.staticTexts.allElementsBoundByIndex.contains { $0.label == expectedEmail } + XCTAssertTrue(sameAccount, "The visible account must match the admitted dedicated fixture") + accountVerified = sameAccount + verifyPlan() + selectAndVerifyAppearance() + tap("settings.font"); tap("Manrope") + tap("Default model"); tap("settings.model.auto:quick") + returnFromModelPicker() + verifyWebPreference() + try alignPlaybackSpeed() + verifiedCell = cellKey + closeSettings() + } + + private func alignPlaybackSpeed() throws { + let forms = app.collectionViews.matching(identifier: "settings.screen") + XCTAssertEqual(forms.count, 1) + let form = forms.firstMatch + guard forms.count == 1, form.exists else { throw ReferenceCaptureIssue.missingControl } + let controls = form.buttons.matching(identifier: "settings.speed") + let speed = controls.firstMatch + func rowIsClear() -> Bool { + let window = app.windows.firstMatch, navigation = app.navigationBars["Settings"].firstMatch + guard controls.count == 1, form.exists, window.exists, navigation.exists, + speed.exists, speed.isEnabled, speed.isHittable + else { return false } + let frames = [app.frame, window.frame, form.frame, navigation.frame, speed.frame] + guard + frames.allSatisfy({ + [$0.minX, $0.minY, $0.width, $0.height].allSatisfy { $0.isFinite } + && $0.width > 0 && $0.height > 0 + }) + else { return false } + let region = frames[0].intersection(frames[1]).intersection(frames[2]) + return !region.isNull && !region.isEmpty && region.contains(frames[4]) + && frames[4].minY >= frames[3].maxY + 8 + && frames[4].maxY <= region.maxY - 8 + } + // Retain the original eight-step row reveal; a hittable clipped row + // alone does not prove that a tap can open its native picker. + for _ in 0..<8 { + if rowIsClear() { break } + scroll(false, in: form, toward: speed) + } + // SwiftUI may create this offscreen row only after its Form is scrolled. + XCTAssertEqual(controls.count, 1) + let clear = rowIsClear() + XCTAssertTrue(clear, "Playback speed must clear the navigation and window boundaries") + guard clear else { throw ReferenceCaptureIssue.missingControl } + func selected() -> Bool { + speed.exists && (speed.value as? String == "1×" || speed.label == "Playback speed, 1×") + } + if !selected() { + speed.tap() + // This option belongs to the picker opened by the exact owned row; + // never scroll the background Form to reveal a popup command. + let options = app.descendants(matching: .any).matching( + NSPredicate( + format: "label == %@ AND (elementType == %d OR elementType == %d)", "1×", + XCUIElement.ElementType.button.rawValue, XCUIElement.ElementType.menuItem.rawValue)) + func visibleOption() -> XCUIElement? { + let candidates = options.allElementsBoundByIndex + guard candidates.count <= 8 else { return nil } + let visible = candidates.filter { + guard $0.exists, $0.isEnabled, $0.isHittable else { return false } + let frame = $0.frame + return [frame.minX, frame.minY, frame.width, frame.height].allSatisfy { $0.isFinite } + && frame.width > 0 && frame.height > 0 && app.frame.contains(frame) + } + return visible.count == 1 ? visible[0] : nil + } + let ready = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in visibleOption() != nil }, object: app) + let result = XCTWaiter.wait(for: [ready], timeout: 5) + XCTAssertEqual(result, .completed) + guard result == .completed, let option = visibleOption() else { + throw ReferenceCaptureIssue.missingControl + } + option.tap() + } + // An already-selected row supplies its readback without a new wait. + if rowIsClear() && selected() { return } + let readback = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in rowIsClear() && selected() }, object: speed) + let result = XCTWaiter.wait(for: [readback], timeout: 10) + XCTAssertEqual(result, .completed) + guard result == .completed, selected() else { throw ReferenceCaptureIssue.missingControl } + } + + private func alignCellIfNeeded() throws { + guard verifiedCell != cellKey else { return } + try openSettings(); verifyPlan(); selectAndVerifyAppearance(); verifyWebPreference() + verifiedCell = cellKey; closeSettings() + } + + private func capture(_ surface: String) { + XCTAssertFalse(app.staticTexts["Configuration unavailable"].exists) + assertFullWindow() + let prefix = "native.\(input.account).\(input.deviceClass).\(input.orientation).\(input.appearance).\(surface)" + let keyboardVisible = keyboardIsVisible() + if surface == "new-chat-keyboard-shown" { XCTAssertTrue(keyboardVisible) } + let screenshot = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + screenshot.name = prefix; screenshot.lifetime = .keepAlways; add(screenshot) + let metadata: [String: Any] = [ + "surface": surface, "account_alias": input.account as String, + "device_class": input.deviceClass as String, "orientation": input.orientation as String, + "appearance": input.appearance as String, "width_points": app.frame.width, + "height_points": app.frame.height, "keyboard_visible": keyboardVisible, + "account_privately_verified": accountVerified, + "observed_appearance_setting": observedAppearance ?? "signed-out-system-appearance", + "appearance_evidence": "Native Settings picker value; rendered screenshot requires visual review", + "observed_web_preference": observedWebPreference ?? "not-applicable", + "web_authority": accountVerified ? "saved-preference-aligned-off" : "not-applicable", + ] + if let data = try? JSONSerialization.data(withJSONObject: metadata, options: .sortedKeys), + let text = String(data: data, encoding: .utf8) + { + let record = XCTAttachment(string: text) + record.name = prefix + ".metadata"; record.lifetime = .keepAlways; add(record) + } + } + + private func openMenuChats() throws -> XCUIElement { + let sidebar = try openLibrary() + XCTAssertTrue(app.textFields["library.search"].waitForExistence(timeout: 20)) + XCTAssertTrue(app.buttons["library.actions"].isHittable) + XCTAssertFalse(app.buttons["library.history"].exists) + XCTAssertFalse(app.navigationBars["All chats"].exists) + return sidebar + } + + private func assertMenuReturned() { + XCTAssertTrue(composer.waitForExistence(timeout: 20)) + XCTAssertFalse(app.collectionViews["library.history-list"].exists) + XCTAssertFalse(app.navigationBars["All chats"].exists) + XCTAssertFalse(app.buttons["library.close"].exists) + if input.deviceClass == "phone" { + let closed = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "exists == false"), + object: app.collectionViews["library.sidebar-list"]) + XCTAssertEqual(XCTWaiter.wait(for: [closed], timeout: 10), .completed) + } else { + XCTAssertNotNil(visibleLibrary(), "Regular iPad retains the root sidebar after selection") + } + } + + private func verifyMenuSelections(_ id: String) throws { + guard input.deviceClass == "tablet", !menuSelectionsVerified else { return } + // Once per batch, select the exact shared chat through the existing + // Projects/Pinned/Recent menu, then verify the distinct New Chat draft. + // Neither path sends or modifies the retained New Chat draft. + tap("chat.new") + XCTAssertTrue(composer.waitForExistence(timeout: 20)) + let messages = app.descendants(matching: .any).matching( + NSPredicate(format: "identifier BEGINSWITH %@", "assistant.message.")) + let emptyNewChat = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "exists == false"), object: messages.firstMatch) + XCTAssertEqual(XCTWaiter.wait(for: [emptyNewChat], timeout: 15), .completed) + let retainedDraft = try XCTUnwrap(composer.value as? String) + _ = try openMenuChats() + let sidebarRow = try XCTUnwrap( + MenuChatUI.find( + id, in: app, projectIDs: input.projectID.map { [$0] }, pageLimit: 16)) + sidebarRow.tap(); assertMenuReturned() + XCTAssertTrue(messages.firstMatch.waitForExistence(timeout: 30)) + let sidebarMessageID = messages.firstMatch.identifier + + let second = try openMenuChats() + let newChat = second.buttons["library.new-chat"] + reveal(newChat, down: true, attempts: 16, in: second) + newChat.tap(); assertMenuReturned() + XCTAssertFalse(messages.firstMatch.exists) + XCTAssertTrue(composer.value as? String == retainedDraft, "New Chat draft must survive menu chat selection") + menuSelectionsVerified = true + sidebarSelectionWitness = (id, sidebarMessageID) + let receipt = XCTAttachment( + string: "sidebar-chat-has-assistant=true; sidebar-new-chat-empty=true; retained-draft=true") + receipt.name = "reference.menu-chat-and-new-chat-selection"; receipt.lifetime = .keepAlways; add(receipt) + } + + private func openConversation(_ id: String?) throws { + guard let id, !id.isEmpty else { + throw XCTSkip("No explicit shared synthetic conversation ID supplied for this surface") + } + try verifyMenuSelections(id) + _ = try openMenuChats() + guard + let row = try MenuChatUI.find( + id, in: app, projectIDs: input.projectID.map { [$0] }, pageLimit: 16) + else { + XCTFail("The explicit shared conversation was not found; refusing to capture another row") + throw ReferenceCaptureIssue.missingControl + } + XCTAssertEqual(row.identifier, "library.chat." + id) + row.tap() + assertMenuReturned() + let messages = app.descendants(matching: .any).matching( + NSPredicate(format: "identifier BEGINSWITH %@", "assistant.message.")) + XCTAssertTrue(messages.firstMatch.waitForExistence(timeout: 30)) + if let witness = sidebarSelectionWitness, witness.conversationID == id { + XCTAssertTrue(messages.matching(identifier: witness.messageID).firstMatch.waitForExistence(timeout: 30)) + } + XCTAssertFalse(app.staticTexts["chat.error"].exists) + } + + func testCaptureAuthenticationSurfaces() throws { + try start(signedIn: false) + capture("entry-email") + tap("Use an existing guest account") + let guestReady = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.app.textFields["auth.identifier"].exists + && self.app.textFields["auth.identifier"].placeholderValue == "Guest account ID" + && self.app.buttons["Sign in with email"].exists + && !self.app.buttons["auth.forgot"].exists + && !self.app.buttons["auth.apple"].exists + && !self.app.buttons["auth.google"].exists + && !self.app.buttons["auth.github"].exists + }, object: app) + XCTAssertEqual(XCTWaiter.wait(for: [guestReady], timeout: 10), .completed) + XCTAssertEqual(app.textFields["auth.identifier"].placeholderValue, "Guest account ID") + capture("existing-guest-sign-in") + tap("Sign in with email") + let emailReady = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.app.textFields["auth.identifier"].exists + && self.app.textFields["auth.identifier"].placeholderValue == "Email" + && self.app.buttons["auth.forgot"].exists && self.app.buttons["auth.forgot"].isEnabled + && self.app.buttons["Create an account"].exists + && self.app.buttons["Create an account"].isEnabled + }, object: app) + XCTAssertEqual(XCTWaiter.wait(for: [emailReady], timeout: 10), .completed) + tap("Create an account") + XCTAssertTrue(app.textFields["Name (optional)"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.textFields["Name (optional)"].exists) + capture("email-signup") + tap("Already have an account? Sign in") + let returnedEmailReady = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.app.textFields["auth.identifier"].exists + && self.app.textFields["auth.identifier"].placeholderValue == "Email" + && self.app.buttons["auth.forgot"].exists && self.app.buttons["auth.forgot"].isEnabled + && self.app.buttons["Create an account"].exists + && self.app.buttons["Create an account"].isEnabled + }, object: app) + XCTAssertEqual(XCTWaiter.wait(for: [returnedEmailReady], timeout: 10), .completed) + tap("auth.forgot") + XCTAssertTrue(app.textFields["auth.reset-email"].waitForExistence(timeout: 15)) + capture("password-reset-request") + // No signup, reset request, provider request or deletion is submitted. + app.terminate() + } + + func testCaptureNewChatAndKeyboard() throws { + try start(signedIn: true); tap("chat.new") + dismissTabletKeyboard() + func hiddenWelcomeFrames() -> [CGRect]? { + let window = app.windows.firstMatch, transcript = app.scrollViews["chat.transcript"] + let navigation = app.mapleChatNavigationBar + guard window.exists, transcript.exists, navigation.exists, composer.exists, + composer.isHittable, !keyboardIsVisible() + else { return nil } + let bounds = window.frame, content = transcript.frame, bar = navigation.frame, editor = composer.frame + let frames = [bounds, content, bar, editor] + guard + frames.allSatisfy({ + [$0.minX, $0.minY, $0.width, $0.height].allSatisfy { $0.isFinite } + && $0.width > 0 && $0.height > 0 + }), bounds.contains(editor) + else { return nil } + let region = content.intersection(bounds) + let candidates = transcript.descendants(matching: .any).matching( + NSPredicate( + format: "label CONTAINS %@ AND label CONTAINS %@", "Research anything...", + "Encrypted and private at every step") + ).allElementsBoundByIndex + guard candidates.count <= 8 else { + XCTFail("New Chat welcome inventory exceeds its bounded check"); return nil + } + let visible = candidates.filter { + $0.exists && $0.isHittable && !$0.frame.intersection(region).isEmpty + } + guard visible.count == 1 else { return nil } + let welcome = visible[0].frame + guard [welcome.minX, welcome.minY, welcome.width, welcome.height].allSatisfy({ $0.isFinite }), + welcome.width > 0, welcome.height > 0, region.contains(welcome), + welcome.minY >= bar.maxY, welcome.maxY <= editor.minY + else { return nil } + return [bounds, content, bar, welcome, editor] + } + var hiddenFrames: [CGRect]?, hiddenSince: TimeInterval?, hiddenObservations = 0 + let hidden = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + guard let frames = hiddenWelcomeFrames() else { + hiddenFrames = nil; hiddenSince = nil; hiddenObservations = 0 + return false + } + let now = ProcessInfo.processInfo.systemUptime + if let hiddenFrames, self.sameKeyboardGeometry(hiddenFrames, frames) { + hiddenObservations += 1 + } else { + hiddenFrames = frames; hiddenSince = now; hiddenObservations = 1 + } + return hiddenObservations >= 2 && now - (hiddenSince ?? now) >= 0.25 + }, object: app) + let hiddenResult = XCTWaiter.wait(for: [hidden], timeout: 15) + let finalHiddenFrames = hiddenWelcomeFrames() + let finalHiddenStable = + finalHiddenFrames.map { frames in + hiddenFrames.map { sameKeyboardGeometry($0, frames) } ?? false + } ?? false + var hiddenGeometry: [String: Double] = [ + "waitCompleted": hiddenResult == .completed ? 1 : 0, + "welcomeReady": finalHiddenFrames != nil ? 1 : 0, + "stableObservations": Double(hiddenObservations), "finalGeometryStable": finalHiddenStable ? 1 : 0, + ] + for (name, frame) in zip( + ["window", "transcript", "navigation", "welcome", "composer"], finalHiddenFrames ?? []) + { + hiddenGeometry[name + "X"] = Double(frame.minX); hiddenGeometry[name + "Y"] = Double(frame.minY) + hiddenGeometry[name + "Width"] = Double(frame.width); hiddenGeometry[name + "Height"] = Double(frame.height) + } + let hiddenData = try JSONSerialization.data(withJSONObject: hiddenGeometry, options: .sortedKeys) + let hiddenRecord = XCTAttachment(string: String(decoding: hiddenData, as: UTF8.self)) + hiddenRecord.name = "New Chat hidden welcome geometry"; hiddenRecord.lifetime = .keepAlways; add(hiddenRecord) + XCTAssertEqual(hiddenResult, .completed) + XCTAssertTrue(finalHiddenFrames != nil, "The full welcome must fit between navigation and composer") + XCTAssertTrue(finalHiddenStable, "Hidden welcome geometry must remain settled before capture") + guard hiddenResult == .completed, finalHiddenFrames != nil, finalHiddenStable else { + throw ReferenceCaptureIssue.missingControl + } + XCTAssertFalse(keyboardIsVisible()) + capture("new-chat-keyboard-hidden") + try pasteSynthetic("Maple comparison — مرحبا 日本語 🌱") + var stableFrames: [CGRect]?, stableSince: TimeInterval?, stableObservations = 0 + let shown = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + let geometry = self.composerKeyboardGeometry() + guard geometry.clear else { + stableFrames = nil; stableSince = nil; stableObservations = 0 + return false + } + let now = ProcessInfo.processInfo.systemUptime + if let stableFrames, self.sameKeyboardGeometry(stableFrames, geometry.frames) { + stableObservations += 1 + } else { + stableFrames = geometry.frames; stableSince = now; stableObservations = 1 + } + return stableObservations >= 2 && now - (stableSince ?? now) >= 0.25 + }, object: app) + let shownResult = XCTWaiter.wait(for: [shown], timeout: 10) + let finalGeometry = composerKeyboardGeometry() + let finalStable = stableFrames.map { sameKeyboardGeometry($0, finalGeometry.frames) } ?? false + var scalars = finalGeometry.scalars + scalars["waitCompleted"] = shownResult == .completed ? 1 : 0 + scalars["stableObservations"] = Double(stableObservations) + scalars["finalGeometryStable"] = finalStable ? 1 : 0 + let data = try JSONSerialization.data(withJSONObject: scalars, options: .sortedKeys) + let geometryRecord = XCTAttachment(string: String(decoding: data, as: UTF8.self)) + geometryRecord.name = "New Chat keyboard clearance geometry" + geometryRecord.lifetime = .keepAlways; add(geometryRecord) + XCTAssertEqual(shownResult, .completed) + XCTAssertTrue(finalGeometry.clear, "Composer and Send must fit above the visible keyboard") + XCTAssertTrue(finalStable, "Keyboard and input geometry must remain settled before capture") + guard shownResult == .completed, finalGeometry.clear, finalStable else { + throw ReferenceCaptureIssue.missingControl + } + capture("new-chat-keyboard-shown") + app.terminate() + } + + private func swipeTranscript( + _ transcript: XCUIElement, down: Bool, velocity: XCUIGestureVelocity, span: CGFloat = 0.5 + ) throws { + let window = app.windows.firstMatch + let navigation = app.mapleChatNavigationBar + let editor = composer + guard transcript.exists, window.exists, navigation.exists, editor.exists else { + XCTFail("Transcript scrolling requires its window, navigation and composer") + throw ReferenceCaptureIssue.missingControl + } + let frame = transcript.frame, bounds = window.frame, bar = navigation.frame, input = editor.frame + guard + [frame, bounds, bar, input].allSatisfy({ + [$0.minX, $0.minY, $0.width, $0.height].allSatisfy { $0.isFinite } + && $0.width > 0 && $0.height > 0 + }) + else { + XCTFail("Transcript scrolling requires finite visible geometry") + throw ReferenceCaptureIssue.missingControl + } + let region = frame.intersection(bounds) + let top = max(region.minY, bar.maxY), bottom = min(region.maxY, input.minY) + guard !region.isNull, !region.isEmpty, region.width >= 44, bottom - top >= 44, + span.isFinite, span > 0, span <= 0.5 + else { + XCTFail("Transcript scrolling has no unobstructed region between navigation and composer") + throw ReferenceCaptureIssue.missingControl + } + // Stay inside the measured band, away from either excluded boundary. + // Coordinates remain relative to this transcript, never a fallback app gesture. + let inset = (bottom - top) * (1 - span) / 2 + let upper = CGPoint(x: region.midX, y: top + inset) + let lower = CGPoint(x: region.midX, y: bottom - inset) + let origin = transcript.coordinate(withNormalizedOffset: .zero) + func coordinate(_ point: CGPoint) -> XCUICoordinate { + origin.withOffset(CGVector(dx: point.x - frame.minX, dy: point.y - frame.minY)) + } + coordinate(down ? upper : lower).press( + forDuration: 0.05, thenDragTo: coordinate(down ? lower : upper), + withVelocity: velocity, thenHoldForDuration: 0) + } + + func testCaptureSharedRichConversation() throws { + try start(signedIn: true); try openConversation(input.conversationID) + let transcript = app.scrollViews["chat.transcript"] + for _ in 0..<10 { try swipeTranscript(transcript, down: true, velocity: .fast) } + capture("rich-conversation-top") + for page in 1...3 { + try swipeTranscript(transcript, down: false, velocity: .slow); capture("rich-conversation-page-\(page)") + } + for _ in 0..<10 { try swipeTranscript(transcript, down: false, velocity: .fast) } + capture("rich-conversation-bottom") + app.terminate() + } + + // Supplemental pixels only: existing rich workflow acceptance stays unchanged. + func testCapturePhoneLandscapeRichMiddleSupplement() throws { + try loadInput() + guard input.account == "pro", input.deviceClass == "phone", + input.orientation == "landscape", input.groups == nil + else { throw ReferenceCaptureIssue.invalidInput } + try start(signedIn: true) + let clock = ContinuousClock(), began = clock.now + func checkWorkBudget() throws { + guard began.duration(to: clock.now) < .seconds(180) else { + XCTFail("Landscape rich supplement exceeded its capture-work budget") + throw ReferenceCaptureIssue.missingControl + } + } + try openConversation(input.conversationID) + XCTAssertFalse(keyboardIsVisible()) + let transcript = app.scrollViews["chat.transcript"] + let assistant = transcript.descendants(matching: .any).matching( + NSPredicate(format: "identifier BEGINSWITH %@", "assistant.message.") + ) + .containing(.button, identifier: "Copy code").firstMatch + let code = assistant.buttons["Copy code"].firstMatch + func clearCodeHeader() -> Bool { + let window = app.windows.firstMatch + let navigation = app.mapleChatNavigationBar + guard transcript.exists, window.exists, navigation.exists, composer.exists, + code.exists, code.isHittable + else { return false } + let frames = [transcript.frame, window.frame, navigation.frame, composer.frame, code.frame] + guard + frames.allSatisfy({ + [$0.minX, $0.minY, $0.width, $0.height].allSatisfy { $0.isFinite } + && $0.width > 0 && $0.height > 0 + }) + else { return false } + let region = frames[0].intersection(frames[1]) + return !region.isNull && !region.isEmpty && region.contains(frames[4]) + && frames[4].minY >= frames[2].maxY + 8 + && frames[4].maxY <= frames[3].minY - 8 + } + for _ in 0..<16 { + if clearCodeHeader() { break } + try checkWorkBudget() + let navigation = app.mapleChatNavigationBar + let towardEarlier = + code.exists && code.frame.midY.isFinite + && code.frame.midY < navigation.frame.maxY + 8 + try swipeTranscript(transcript, down: towardEarlier, velocity: .slow) + } + try checkWorkBudget() + let headerIsClear = clearCodeHeader() + XCTAssertTrue(headerIsClear, "Assistant code header must clear navigation and composer") + guard headerIsClear else { throw ReferenceCaptureIssue.missingControl } + capture("rich-supplement-code-anchor") + // The table precedes code in this already-qualified synthetic corpus. + // Overlapping real frames cover earlier rows and the response below code; + // names deliberately make no automatic table/math/Web visibility claim. + for page in 1...2 { + try checkWorkBudget() + try swipeTranscript(transcript, down: true, velocity: .slow) + capture("rich-supplement-earlier-\(page)") + } + for page in 1...13 { + try checkWorkBudget() + try swipeTranscript(transcript, down: false, velocity: .slow) + capture("rich-supplement-later-\(page)") + } + try checkWorkBudget() + app.terminate() + } + + func testCaptureSharedReasoningDisclosure() throws { + try start(signedIn: true); try openConversation(input.conversationID) + let transcript = app.scrollViews["chat.transcript"] + for _ in 0..<10 { transcript.swipeDown(velocity: .fast) } + let thinking = app.buttons["Thinking"].firstMatch + for _ in 0..<16 where !thinking.isHittable { transcript.swipeUp(velocity: .slow) } + XCTAssertTrue(thinking.exists); XCTAssertTrue(thinking.isHittable) + capture("reasoning-collapsed") + thinking.tap(); capture("reasoning-expanded") + // The paired screenshots establish expansion and content layout; merely + // tapping this real disclosure is not an automatic visual-parity pass. + app.terminate() + } + + func testLeadingEdgeMenuAndClosePreserveDraftAndTranscriptPosition() throws { + try start(signedIn: true) + try XCTSkipUnless(input.deviceClass == "phone", "Leading-edge menu applies to compact navigation") + try openConversation(input.conversationID) + try replaceSyntheticText(composer, with: "Unsent edge-navigation comparison draft", in: app) + // Dismiss through Menu before measuring the transcript anchor. + let keyboard = app.keyboards.firstMatch + XCTAssertTrue(keyboard.exists) + let keys = keyboard.keys.allElementsBoundByIndex + keyboard.buttons.allElementsBoundByIndex + XCTAssertLessThanOrEqual(keys.count, 160) + XCTAssertTrue(keys.contains { $0.exists && $0.isHittable && $0.frame.intersects(app.frame) }) + tap("chat.menu") + XCTAssertTrue(app.buttons["library.close"].waitForExistence(timeout: 15)) + try assertPhysicalLeftDrawer() + let hidden = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in !self.app.keyboards.firstMatch.exists }, object: app) + XCTAssertEqual(XCTWaiter.wait(for: [hidden], timeout: 10), .completed) + XCTAssertFalse(app.keyboards.firstMatch.exists) + tap("library.close") + XCTAssertTrue(composer.waitForExistence(timeout: 15)) + XCTAssertEqual(composer.value as? String, "Unsent edge-navigation comparison draft") + XCTAssertFalse(app.keyboards.firstMatch.exists) + let transcript = app.scrollViews["chat.transcript"] + // Retain an actual visible message container rather than infer position + // from a scroll-view offset that XCTest does not expose. + let messages = transcript.descendants(matching: .any).matching( + NSPredicate(format: "identifier BEGINSWITH %@", "assistant.message.") + ).allElementsBoundByIndex + let visible = messages.first { + $0.frame.intersects(transcript.frame) && $0.frame.width > 40 && $0.frame.height > 20 + } + let anchor = try XCTUnwrap(visible) + let identifier = anchor.identifier, originalY = anchor.frame.minY + let originalHeight = anchor.frame.height + let start = app.coordinate(withNormalizedOffset: .zero).withOffset( + CGVector(dx: app.frame.minX + 4, dy: transcript.frame.midY)) + let end = app.coordinate(withNormalizedOffset: .zero).withOffset( + CGVector(dx: app.frame.minX + app.frame.width * 0.65, dy: transcript.frame.midY)) + start.press(forDuration: 0.1, thenDragTo: end, withVelocity: .fast, thenHoldForDuration: 0) + XCTAssertTrue(app.buttons["library.close"].waitForExistence(timeout: 15)) + try assertPhysicalLeftDrawer() + capture("leading-edge-menu") + // The full-screen menu lives behind this retained chat. Its X returns + // to the same runtime, draft and visible transcript position. + tap("library.close") + XCTAssertTrue(composer.waitForExistence(timeout: 15)) + XCTAssertEqual(composer.value as? String, "Unsent edge-navigation comparison draft") + let afterEdgeClose = transcript.descendants(matching: .any).matching(identifier: identifier).firstMatch + XCTAssertTrue(afterEdgeClose.exists) + XCTAssertEqual(afterEdgeClose.frame.minY, originalY, accuracy: 3) + XCTAssertEqual(afterEdgeClose.frame.height, originalHeight, accuracy: 3) + // A slow short pull held before release has no flick momentum. + // Native navigation must settle back without opening the menu. + let originalX = afterEdgeClose.frame.minX + let cancelledStart = app.coordinate(withNormalizedOffset: .zero).withOffset( + CGVector(dx: app.frame.minX + 4, dy: transcript.frame.midY)) + let cancelledEnd = cancelledStart.withOffset(CGVector(dx: 44, dy: 0)) + cancelledStart.press( + forDuration: 0.1, thenDragTo: cancelledEnd, + withVelocity: .slow, thenHoldForDuration: 0.3) + let afterCancelledPreview = transcript.descendants(matching: .any).matching(identifier: identifier).firstMatch + let settled = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + guard !self.app.buttons["library.close"].exists, + !self.app.descendants(matching: .any).matching(identifier: "library.drawer").firstMatch.exists, + self.composer.exists, self.composer.isHittable, + self.composer.value as? String == "Unsent edge-navigation comparison draft", + afterCancelledPreview.exists + else { return false } + let frame = afterCancelledPreview.frame + return abs(frame.minX - originalX) <= 3 && abs(frame.minY - originalY) <= 3 + && abs(frame.height - originalHeight) <= 3 + }, object: app) + let settledResult = XCTWaiter.wait(for: [settled], timeout: 10) + let cancelledFrame = afterCancelledPreview.exists ? afterCancelledPreview.frame : .zero + let cancellationGeometry: [String: Double] = [ + "waitCompleted": settledResult == .completed ? 1 : 0, + "originalAnchorX": Double(originalX), "originalAnchorY": Double(originalY), + "originalAnchorHeight": Double(originalHeight), + "returnedAnchorX": Double(cancelledFrame.minX), "returnedAnchorY": Double(cancelledFrame.minY), + "returnedAnchorHeight": Double(cancelledFrame.height), + "closeExists": app.buttons["library.close"].exists ? 1 : 0, + "drawerExists": app.descendants(matching: .any).matching(identifier: "library.drawer").firstMatch.exists + ? 1 : 0, + "composerExists": composer.exists ? 1 : 0, "composerHittable": composer.isHittable ? 1 : 0, + ].mapValues { $0.isFinite ? $0 : -Double.greatestFiniteMagnitude } + let cancellationData = try JSONSerialization.data(withJSONObject: cancellationGeometry, options: .sortedKeys) + let cancellationRecord = XCTAttachment(string: String(decoding: cancellationData, as: UTF8.self)) + cancellationRecord.name = "Physical-left cancelled preview geometry" + cancellationRecord.lifetime = .keepAlways; add(cancellationRecord) + // Compare query identity and bounded geometry without recording message content. + func elementGeometry(_ element: XCUIElement) -> [String: Any] { + let exists = element.exists + let frame = exists ? element.frame : .zero + let finite = [frame.minX, frame.minY, frame.width, frame.height].allSatisfy { $0.isFinite } + let canQueryHittability = exists && finite && frame.width > 0 && frame.height > 0 + return [ + "exists": exists, "type": element.elementType.rawValue, + "x": frame.minX.isFinite ? Double(frame.minX) : -Double.greatestFiniteMagnitude, + "y": frame.minY.isFinite ? Double(frame.minY) : -Double.greatestFiniteMagnitude, + "width": frame.width.isFinite ? Double(frame.width) : -Double.greatestFiniteMagnitude, + "height": frame.height.isFinite ? Double(frame.height) : -Double.greatestFiniteMagnitude, + "hittabilityQueried": canQueryHittability, + "hittable": canQueryHittability ? element.isHittable : false, + ] + } + func candidateGeometry(_ query: XCUIElementQuery) -> [String: Any] { + let count = query.count + let leafTypes = [ + XCUIElement.ElementType.staticText.rawValue, XCUIElement.ElementType.textView.rawValue, + XCUIElement.ElementType.button.rawValue, XCUIElement.ElementType.image.rawValue, + XCUIElement.ElementType.link.rawValue, + ] + let candidates: [[String: Any]] = (0.. 200, frame.height > 0, bounds.width > 0, bounds.height > 0 + else { return false } + return abs(frame.minX - bounds.minX) <= 1 && abs(frame.maxX - bounds.maxX) <= 1 + && abs(frame.minY - bounds.minY) <= 1 && abs(frame.maxY - bounds.maxY) <= 1 + && abs(frame.width - bounds.width) <= 1 && abs(frame.height - bounds.height) <= 1 + }, object: drawer) + let result = XCTWaiter.wait(for: [positioned], timeout: 10) + let count = candidates.count + let typed = app.otherElements.matching(identifier: "library.drawer") + var geometry: [String: Double] = [ + "waitCompleted": result == .completed ? 1 : 0, + "candidateCount": Double(min(count, 9)), "candidateLimitExceeded": count > 8 ? 1 : 0, + "typedOtherCount": Double(min(typed.count, 9)), + ] + for (name, element) in [ + ("typedOther", typed.firstMatch), ("drawer", drawer), ("window", window), + ("scrim", app.buttons["library.scrim"]), + ("alertDrawer", app.alerts["library.drawer"]), ("sheetDrawer", app.sheets["library.drawer"]), + ("sidebar", app.collectionViews["library.sidebar-list"]), + ("close", app.buttons["library.close"]), + ("searchField", app.textFields["library.search"]), + ("menuActions", app.buttons["library.actions"]), + ] { + let exists = element.exists + let frame = exists ? element.frame : .zero + geometry[name + "Exists"] = exists ? 1 : 0 + geometry[name + "Hittable"] = exists && element.isHittable ? 1 : 0 + geometry[name + "Type"] = exists ? Double(element.elementType.rawValue) : 0 + geometry[name + "X"] = Double(frame.minX) + geometry[name + "Y"] = Double(frame.minY) + geometry[name + "Width"] = Double(frame.width) + geometry[name + "Height"] = Double(frame.height) + } + geometry = geometry.mapValues { $0.isFinite ? $0 : -Double.greatestFiniteMagnitude } + let data = try JSONSerialization.data(withJSONObject: geometry, options: .sortedKeys) + let record = XCTAttachment(string: String(decoding: data, as: UTF8.self)) + record.name = "Physical-left drawer geometry"; record.lifetime = .keepAlways; add(record) + XCTAssertLessThanOrEqual(count, 8) + XCTAssertEqual(count, 1, "The full-screen menu must have one accessibility owner") + XCTAssertEqual(typed.count, 1) + XCTAssertEqual(drawer.elementType, .other, "The menu must remain scene navigation") + XCTAssertEqual(result, .completed) + XCTAssertFalse(app.alerts["library.drawer"].exists) + XCTAssertFalse(app.sheets["library.drawer"].exists) + XCTAssertFalse(app.buttons["library.scrim"].exists, "The menu fills the screen without an exposed chat strip") + XCTAssertFalse(composer.exists && composer.isHittable, "The offscreen chat must not accept input") + let close = app.buttons["library.close"] + XCTAssertTrue(close.exists); XCTAssertTrue(close.isHittable) + XCTAssertGreaterThanOrEqual(close.frame.width, 43.5) + XCTAssertGreaterThanOrEqual(close.frame.height, 43.5) + XCTAssertTrue(window.frame.insetBy(dx: -0.5, dy: -0.5).contains(close.frame)) + } + + func testCaptureSharedImageConversation() throws { + try start(signedIn: true); try openConversation(input.imageConversationID) + let image = app.buttons["View message image"].firstMatch + let transcript = app.scrollViews["chat.transcript"] + let window = app.windows.firstMatch + let navigation = app.mapleChatNavigationBar + func imageIsVisible() -> Bool { + guard image.exists, image.isEnabled, image.isHittable, + transcript.exists, window.exists, navigation.exists, composer.exists + else { return false } + let frames = [image.frame, transcript.frame, window.frame, navigation.frame, composer.frame] + guard + frames.allSatisfy({ + [$0.minX, $0.minY, $0.width, $0.height].allSatisfy { $0.isFinite } + && $0.width > 0 && $0.height > 0 + }) + else { return false } + let region = transcript.frame.intersection(window.frame) + let top = max(region.minY, navigation.frame.maxY + 12) + let bottom = min(region.maxY, composer.frame.minY - 24) + let target = CGRect(x: image.frame.midX - 22, y: image.frame.midY - 22, width: 44, height: 44) + return !region.isNull && !region.isEmpty && image.frame.contains(target) + && region.contains(target) && target.minY >= top && target.maxY <= bottom + } + // A retained thumbnail can report hittable even while under the composer. + // Seek earlier content only while the thumbnail is absent or above it. + for _ in 0..<12 where !imageIsVisible() { + if image.exists && image.frame.midY >= composer.frame.minY - 24 { break } + try swipeTranscript(transcript, down: true, velocity: .slow) + } + // The shared image can belong to a later turn. If the earlier-content + // search reaches the top, seek later content within the same transcript. + for _ in 0..<12 where !imageIsVisible() { + try swipeTranscript(transcript, down: false, velocity: .slow) + } + XCTAssertTrue(image.waitForExistence(timeout: 15)); XCTAssertTrue(image.isHittable) + let visible = imageIsVisible() + let record = XCTAttachment( + string: + "visible=\(visible); image=\(image.frame); window=\(window.frame); navigation=\(navigation.frame); composer=\(composer.frame)" + ) + record.name = "Image preview tap clearance"; record.lifetime = .keepAlways; add(record) + XCTAssertTrue(visible, "The image tap target must clear navigation and composer") + guard visible else { throw ReferenceCaptureIssue.missingControl } + capture("image-message") + guard imageIsVisible() else { throw ReferenceCaptureIssue.missingControl } + image.tap(); XCTAssertTrue(app.buttons["images.close"].waitForExistence(timeout: 15)) + XCTAssertTrue(app.images["Attached image"].waitForExistence(timeout: 20)) + XCTAssertFalse(app.staticTexts["Image unavailable"].exists) + capture("image-fullscreen") + tap("images.close"); app.terminate() + } + + func testCaptureLibraryAndHistory() throws { + try start(signedIn: true); let library = try openLibrary() + capture("library") + XCTAssertTrue(app.textFields["library.search"].exists) + XCTAssertTrue(app.buttons["library.actions"].isHittable) + XCTAssertFalse(app.buttons["library.history"].exists) + XCTAssertFalse(app.navigationBars["All chats"].exists) + XCTAssertFalse(app.navigationBars["Maple"].exists, "The logo remains without a separate Maple title") + let expectsEmptyLibrary = input.account == "free" && input.conversationID == nil && input.projectID == nil + if input.deviceClass == "phone" { + if let projectID = input.projectID { + try MenuChatUI.expand(projectID, in: app, pageLimit: 24) + } + let window = app.windows.firstMatch + let row = library.buttons.matching( + NSPredicate(format: "identifier BEGINSWITH %@", "library.chat.") + ).firstMatch + let contentWitness = expectsEmptyLibrary ? library.buttons["library.new-chat"] : row + if !expectsEmptyLibrary { + // SwiftUI List materializes offscreen rows after scrolling. + reveal(row, attempts: 24, in: library) + XCTAssertTrue(row.waitForExistence(timeout: 20)) + } + let expanded = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + guard library.exists, window.exists, contentWitness.exists, contentWitness.isHittable else { + return false + } + if expectsEmptyLibrary { + guard library.staticTexts["Pinned"].exists, library.staticTexts["Recent"].exists, + library.buttons["library.new-project"].exists, !row.exists, + !self.app.buttons["library.clear-search"].exists, + !self.app.buttons["library.pinned.load-more"].exists, + !self.app.buttons["library.recent.load-more"].exists, + !self.app.staticTexts["library.error"].exists, + !library.activityIndicators.allElementsBoundByIndex.contains(where: { $0.exists }) + else { return false } + } + let frame = library.frame, bounds = window.frame + return frame.width > 0 && frame.height > 0 && contentWitness.frame.intersects(frame) + && abs(frame.width - bounds.width) <= 1 + && abs(frame.minX - bounds.minX) <= 1 && abs(frame.maxX - bounds.maxX) <= 1 + }, object: library) + let result = XCTWaiter.wait(for: [expanded], timeout: 10) + var geometry: [String: Double] = ["waitCompleted": result == .completed ? 1 : 0] + for (name, element) in [ + ("menuList", library), ("drawer", app.otherElements["library.drawer"]), + ("window", window), ("scrim", app.buttons["library.scrim"]), + ] { + let exists = element.exists + let frame = exists ? element.frame : .zero + geometry[name + "Exists"] = exists ? 1 : 0 + geometry[name + "Hittable"] = exists && element.isHittable ? 1 : 0 + geometry[name + "X"] = Double(frame.minX) + geometry[name + "Y"] = Double(frame.minY) + geometry[name + "Width"] = Double(frame.width) + geometry[name + "Height"] = Double(frame.height) + } + geometry = geometry.mapValues { $0.isFinite ? $0 : -Double.greatestFiniteMagnitude } + let data = try JSONSerialization.data(withJSONObject: geometry, options: .sortedKeys) + let record = XCTAttachment(string: String(decoding: data, as: UTF8.self)) + record.name = "Menu chat-list geometry"; record.lifetime = .keepAlways; add(record) + XCTAssertEqual(result, .completed) + XCTAssertFalse(app.buttons["library.scrim"].exists) + } + capture("menu-chat-list") + tap("library.actions"); try tapSelectionMenuCommand("Select chats") + let move = app.buttons["Move selected chats"].firstMatch + let delete = app.buttons["Delete selected chats"].firstMatch + XCTAssertTrue(move.exists); XCTAssertFalse(move.isEnabled) + XCTAssertTrue(delete.exists); XCTAssertFalse(delete.isEnabled) + capture("menu-selection") + tap("library.actions"); try tapSelectionMenuCommand("Done selecting") + XCTAssertFalse(move.exists); XCTAssertFalse(delete.exists) + app.terminate() + } + + func testCaptureSharedProjectAndInstructions() throws { + try start(signedIn: true) + guard let id = input.projectID else { throw XCTSkip("No explicit shared synthetic project ID supplied") } + let library = try openLibrary() + let project = app.buttons.matching(identifier: "library.project." + id).firstMatch + reveal(project, down: true, attempts: 24, in: library) + project.press(forDuration: 1); tap("library.project.manage." + id) + capture("project") + tap("Project instructions") + XCTAssertTrue(app.textViews["library.instructions-text"].waitForExistence(timeout: 20)) + capture("project-instructions") + tap("library.instructions-cancel"); app.terminate() + } + + func testCaptureReadOnlyModelsAndFeatureAccess() throws { + try start(signedIn: true); tap("chat.new") + try replaceSyntheticText(composer, with: "Unsent comparison draft", in: app) + composer.tap() + tap("chat.model") + XCTAssertTrue(app.buttons["settings.model.auto:quick"].waitForExistence(timeout: 20)) + let web = app.switches["model.web-search"] + XCTAssertTrue(web.waitForExistence(timeout: 20)) + XCTAssertEqual(web.value as? String, observedWebPreference) + XCTAssertTrue(web.isEnabled) + XCTAssertFalse(app.buttons["chat.web-search"].exists) + capture("model-selection") + let original = try XCTUnwrap(observedWebPreference) + XCTAssertTrue(original == "0" || original == "1") + let changed = original == "1" ? "0" : "1" + tapWebPreference(web) + let flipped = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "value == %@", changed), object: web) + XCTAssertEqual(XCTWaiter.wait(for: [flipped], timeout: 10), .completed) + try dismissChatOptions() + XCTAssertEqual(composer.value as? String, "Unsent comparison draft") + composer.tap() + tap("chat.model") + let reopened = app.switches["model.web-search"] + XCTAssertTrue(reopened.waitForExistence(timeout: 20)); XCTAssertTrue(reopened.isEnabled) + XCTAssertEqual(reopened.value as? String, changed, "The picker must read back the saved preference") + tapWebPreference(reopened) + let restored = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "value == %@", original), object: reopened) + XCTAssertEqual(XCTWaiter.wait(for: [restored], timeout: 10), .completed) + try dismissChatOptions() + XCTAssertEqual(composer.value as? String, "Unsent comparison draft") + + // Test ordinary toolbar touches immediately after dismissal. No edge + // gesture, focus mutation or projection reset may repair admission first. + let libraryWasVisible = visibleLibrary() != nil + let menu = app.mapleMenuButton + XCTAssertTrue(menu.waitForExistence(timeout: 10)) + XCTAssertTrue(menu.isEnabled && menu.isHittable) + menu.tap() + let menuChanged = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in (self.visibleLibrary() != nil) != libraryWasVisible }, object: app) + XCTAssertEqual(XCTWaiter.wait(for: [menuChanged], timeout: 15), .completed) + let close = app.buttons["library.close"].firstMatch + if close.exists { + XCTAssertTrue(close.isEnabled && close.isHittable); close.tap() + } else { + // A regular-width sidebar uses the same Menu toggle to close. + let sidebarToggle = app.mapleMenuButton + XCTAssertTrue(sidebarToggle.exists && sidebarToggle.isEnabled && sidebarToggle.isHittable) + sidebarToggle.tap() + } + let menuRestored = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in (self.visibleLibrary() != nil) == libraryWasVisible }, object: app) + XCTAssertEqual(XCTWaiter.wait(for: [menuRestored], timeout: 15), .completed) + let newChat = app.buttons["chat.new"].firstMatch + XCTAssertTrue(newChat.waitForExistence(timeout: 10)) + XCTAssertTrue(newChat.isEnabled && newChat.isHittable); newChat.tap() + XCTAssertTrue(composer.waitForExistence(timeout: 10)) + XCTAssertEqual(composer.value as? String, "Unsent comparison draft") + XCTAssertTrue(newChat.exists && newChat.isEnabled && newChat.isHittable) + XCTAssertFalse(app.buttons["chat.stop"].exists) + // This test is already on New Chat: its saved draft is intentionally + // reused. These checks establish touch admission and draft retention, + // while changing from an existing conversation is verified separately. + capture("options-dismissal-toolbar-touch-and-draft-retention") + if input.account == "free" { + for (control, surface) in [("dictation.start", "dictation-access")] { + XCTAssertEqual(app.buttons[control].value as? String, "Unavailable") + tap(control) + XCTAssertTrue(app.staticTexts["media.access.message"].waitForExistence(timeout: 15)) + capture(surface); tap("media.access.close") + XCTAssertEqual(composer.value as? String, "Unsent comparison draft") + } + XCTAssertEqual(app.buttons["chat.attach"].value as? String, "Unavailable") + tap("chat.attach"); tap("Photo library") + XCTAssertTrue(app.staticTexts["media.access.message"].waitForExistence(timeout: 15)) + capture("image-access"); tap("media.access.close") + XCTAssertEqual(composer.value as? String, "Unsent comparison draft") + } + app.terminate() + } + + func testCaptureReadOnlyAccountAndPreferences() throws { + try start(signedIn: true); try openSettings() + let forms = app.collectionViews.matching(identifier: "settings.screen") + XCTAssertEqual(forms.count, 1) + let form = forms.firstMatch + XCTAssertTrue(form.exists) + guard forms.count == 1, form.exists else { throw ReferenceCaptureIssue.missingControl } + func tapSettingsRow(_ id: String, down: Bool = false) { + let button = form.buttons[id].firstMatch + reveal(button, down: down, in: form) + XCTAssertTrue(button.isEnabled); button.tap() + } + for _ in 0..<5 { scroll(true, in: form) } + capture("settings-account-plan") + let voice = form.buttons["settings.voice"].firstMatch + let speed = form.buttons["settings.speed"].firstMatch + let notes = form.staticTexts.matching( + NSPredicate( + format: "label == %@", + "Text size follows your system settings. Autoplay is off each time Maple launches.")) + let note = notes.firstMatch + func preferencesAreVisible() -> Bool { + let window = app.windows.firstMatch, navigation = app.navigationBars["Settings"].firstMatch + guard forms.count == 1, form.exists, window.exists, navigation.exists, + voice.exists, voice.isHittable, speed.exists, speed.isHittable, + notes.count == 1, note.exists + else { return false } + let bounds = app.frame, windowFrame = window.frame, formFrame = form.frame + let bar = navigation.frame, voiceFrame = voice.frame, speedFrame = speed.frame, noteFrame = note.frame + guard + [bounds, windowFrame, formFrame, bar, voiceFrame, speedFrame, noteFrame].allSatisfy({ + [$0.minX, $0.minY, $0.width, $0.height].allSatisfy { $0.isFinite } + && $0.width > 0 && $0.height > 0 + }) + else { return false } + let region = formFrame.intersection(bounds).intersection(windowFrame) + let top = max(region.minY, bar.maxY) + guard !region.isNull, !region.isInfinite, region.width > 0, region.maxY > top else { return false } + let visible = CGRect(x: region.minX, y: top, width: region.width, height: region.maxY - top) + return [voiceFrame, speedFrame, noteFrame].allSatisfy { visible.contains($0) } + } + // Keep the original reveal allowance, with the named Form as its owner. + // A hittable Voice row alone cannot qualify the lower Preferences content. + for _ in 0..<8 { + if preferencesAreVisible() { break } + scroll(false, in: form, toward: note) + } + XCTAssertTrue(voice.exists); XCTAssertTrue(voice.isHittable) + let preferencesVisible = preferencesAreVisible() + XCTAssertTrue(preferencesVisible, "Voice, playback speed and their explanatory note must be fully visible") + guard preferencesVisible else { throw ReferenceCaptureIssue.missingControl } + capture("settings-preferences") + tapSettingsRow("settings.theme", down: true); capture("settings-appearance-options") + tap(input.appearance == "light" ? "Light" : "Dark") + tapSettingsRow("Default model"); capture("settings-models") + returnFromModelPicker() + tapSettingsRow("Default instructions") + XCTAssertTrue(app.textViews["library.instructions-text"].waitForExistence(timeout: 20)) + capture("account-instructions"); tap("library.instructions-cancel") + reveal(form.buttons["Contact support"].firstMatch, in: form); capture("settings-about") + app.terminate() + } + + func testCaptureSecurityAndDeletionWarnings() throws { + try start(signedIn: true); try openSettings() + tap("Change password") + XCTAssertTrue(app.secureTextFields["auth.current-password"].waitForExistence(timeout: 15)) + capture("change-password"); tap("Done") + tap("Reset password") + XCTAssertTrue(app.textFields["auth.reset-email"].waitForExistence(timeout: 15)) + capture("signed-in-password-reset"); tap("Done") + tap("settings.delete-history") + XCTAssertTrue(app.buttons["Delete all chats"].waitForExistence(timeout: 10)) + capture("delete-history-warning") + if app.buttons["Cancel"].exists { + tap("Cancel") + } else { + // Confirmation popovers can omit Cancel. Use the leading app + // margin, and never place a dismissal tap on the destructive action. + let window = app.windows.firstMatch, warning = app.buttons["Delete all chats"].firstMatch + guard window.exists, warning.exists else { throw ReferenceCaptureIssue.missingControl } + let bounds = window.frame, appBounds = app.frame, action = warning.frame + let point = CGPoint(x: appBounds.minX + appBounds.width * 0.02, y: appBounds.minY + appBounds.height * 0.5) + guard + [bounds, appBounds, action].allSatisfy({ + [$0.minX, $0.minY, $0.width, $0.height].allSatisfy { $0.isFinite } + && $0.width > 0 && $0.height > 0 + }), point.x.isFinite, point.y.isFinite, bounds.contains(point), !action.contains(point) + else { + XCTFail("Deletion warning dismissal requires a safe point outside the destructive action") + throw ReferenceCaptureIssue.missingControl + } + app.coordinate(withNormalizedOffset: CGVector(dx: 0.02, dy: 0.5)).tap() + } + let dismissed = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in !self.app.buttons["Delete all chats"].exists }, object: app) + XCTAssertEqual(XCTWaiter.wait(for: [dismissed], timeout: 10), .completed) + tap("settings.delete-account") + XCTAssertTrue(app.textFields["auth.delete-confirmation"].waitForExistence(timeout: 15)) + XCTAssertFalse(app.buttons["auth.request-delete"].isEnabled) + capture("delete-account-warning"); tap("Done") + // No deletion phrase, reset confirmation, or destructive action is submitted. + app.terminate() + } + + func testCaptureManualReplyPlayer() throws { + try start(signedIn: true) + try XCTSkipUnless(input.account == "pro", "Read aloud requires the dedicated eligible Pro fixture") + try openConversation(input.conversationID) + let speaker = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH %@", "message.speak.")).firstMatch + let transcript = app.scrollViews["chat.transcript"] + for _ in 0..<12 { + let top = app.navigationBars.firstMatch.frame.maxY + 12, bottom = composer.frame.minY - 24 + if speaker.isHittable && speaker.frame.minY >= top && speaker.frame.maxY <= bottom { break } + if speaker.frame.maxY > bottom { + transcript.swipeUp(velocity: .slow) + } else { + transcript.swipeDown(velocity: .slow) + } + } + XCTAssertGreaterThanOrEqual(speaker.frame.minY, app.navigationBars.firstMatch.frame.maxY + 12) + XCTAssertLessThanOrEqual(speaker.frame.maxY, composer.frame.minY - 24) + XCTAssertTrue(speaker.isHittable); XCTAssertTrue(speaker.isEnabled); speaker.tap() + XCTAssertTrue(app.buttons["audio.close"].waitForExistence(timeout: 15)) + capture("player-preparing-or-ready") + let playable = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "enabled == true"), object: app.buttons["audio.back"]) + XCTAssertEqual(XCTWaiter.wait(for: [playable], timeout: 90), .completed) + if app.buttons["audio.play-pause"].label == "Pause reply audio" { tap("audio.play-pause") } + capture("player-paused") + try openLibrary(); capture("player-in-library") + tap("audio.close"); app.terminate() + } +} + +private enum ReferenceCaptureIssue: Error { case invalidInput, missingControl } diff --git a/apps/maple-ios/UITests/RendererUITests.swift b/apps/maple-ios/UITests/RendererUITests.swift new file mode 100644 index 000000000..500f9a279 --- /dev/null +++ b/apps/maple-ios/UITests/RendererUITests.swift @@ -0,0 +1,1055 @@ +import XCTest + +@MainActor +final class RendererUITests: XCTestCase { + private var app: XCUIApplication! + + private func launchFixture() { + continueAfterFailure = false + app = XCUIApplication() + app.launchArguments = ["--maple-renderer-fixture"] + app.launch() + XCTAssertTrue( + app.textFields["renderer.paste"].waitForExistence(timeout: 15) + || app.textViews["renderer.paste"].waitForExistence(timeout: 5)) + } + + func testHostedMessageEquationEnrichmentRetainsLifecycleOwnership() { + runHostedMessageEquationLifecycle(attachedWindow: false) + } + + func testHostedMessageEquationEnrichmentWithOwnedWindowAttachment() { + runHostedMessageEquationLifecycle(attachedWindow: true) + } + + func testColdProductionMessageViewPublishesSixEquations() { + continueAfterFailure = false + app = XCUIApplication() + app.launchArguments = [ + "--maple-renderer-fixture", "--maple-renderer-lifecycle", "--maple-renderer-cold-equations", + ] + app.launch() + let state = app.staticTexts["renderer.cold-state"] + XCTAssertTrue(state.waitForExistence(timeout: 15)) + let complete = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "label == %@", "Published equations: 6; ink: 6"), object: state) + let result = XCTWaiter.wait(for: [complete], timeout: 30) + let diagnostic = XCTAttachment(string: state.label) + diagnostic.name = "Cold production MessageView equation publication" + diagnostic.lifetime = .keepAlways; add(diagnostic) + let pixels = XCTAttachment(screenshot: app.screenshot()) + pixels.name = "Cold production MessageView six equation pixels" + pixels.lifetime = .keepAlways; add(pixels) + XCTAssertEqual(result, .completed) + } + + private func runHostedMessageEquationLifecycle(attachedWindow: Bool) { + continueAfterFailure = false + app = XCUIApplication() + app.launchArguments = ["--maple-renderer-fixture", "--maple-renderer-lifecycle"] + if attachedWindow { app.launchArguments.append("--maple-renderer-owned-window") } + // Both legacy selectors now exercise the same production-owned host. + var attachment = [ + "windowHostRequested": 1, "windowHostFound": 1, "windowHostAttached": 1, "windowHostDetached": 1, + ] + attachment["navigationDeadlineMilliseconds"] = 12_000 + app.launch() + let state = app.staticTexts["renderer.lifecycle-state"] + XCTAssertTrue(state.waitForExistence(timeout: 15)) + waitForMessageLifecycle( + [ + "shown": 1, "prepared": 1, "currentSource": 1, "currentGeneration": 1, + "appeared": 1, "disappeared": 0, "sources": 2, "published": 0, + "waiting": 1, "entered": 1, "cancelled": 0, "started": 0, "failed": 0, + ], phase: "prepared before equation release") + // Copy code is created only by the actual prepared MessageView branch. + XCTAssertTrue(app.buttons["Copy code"].exists) + XCTAssertTrue(app.buttons["Copy code"].isHittable) + app.buttons["renderer.lifecycle-release"].tap() + waitForMessageLifecycle( + [ + "published": 2, "waiting": 0, "released": 1, "cancelled": 0, + "started": 1, "completed": 1, "attachments": 2, "ink": 2, "failed": 0, + "appeared": 1, "disappeared": 0, "currentSource": 1, "currentGeneration": 1, + ].merging(attachment) { _, new in new }, phase: "actual equation attachments published", timeout: 30) + + app.buttons["renderer.lifecycle-revise"].tap() + waitForMessageLifecycle( + [ + "published": 0, "waiting": 1, "entered": 2, "started": 1, "currentSource": 1, + ], phase: "pending old generation") + app.buttons["renderer.lifecycle-revoke"].tap() + waitForMessageLifecycle( + [ + "published": 0, "waiting": 1, "entered": 3, "cancelled": 1, "started": 1, + "currentSource": 1, "currentGeneration": 1, "appeared": 1, "disappeared": 0, + ], phase: "generation revoked pending equation work") + app.buttons["renderer.lifecycle-release"].tap() + waitForMessageLifecycle( + [ + "published": 2, "waiting": 0, "released": 2, "cancelled": 1, + "started": 2, "completed": 2, "attachments": 2, "ink": 2, "failed": 0, + "currentSource": 1, "currentGeneration": 1, "appeared": 1, "disappeared": 0, + ].merging(attachment) { _, new in new }, phase: "replacement generation published", timeout: 30) + + app.buttons["renderer.lifecycle-revise"].tap() + waitForMessageLifecycle( + [ + "published": 0, "waiting": 1, "entered": 4, "started": 2, "currentSource": 1, + ], phase: "pending before actual removal") + app.buttons["renderer.lifecycle-remove"].tap() + waitForMessageLifecycle( + [ + "shown": 0, "prepared": 0, "published": 0, "waiting": 0, "cancelled": 2, + "started": 2, "completed": 2, "appeared": 1, "disappeared": 1, "failed": 0, + ], phase: "actual disappearance cancelled pending work") + } + + private func waitForMessageLifecycle(_ expected: [String: Int], phase: String, timeout: TimeInterval = 10) { + let state = app.staticTexts["renderer.lifecycle-state"] + let ready = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + guard let data = state.label.data(using: .utf8), + let values = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return false } + return expected.allSatisfy { (values[$0.key] as? NSNumber)?.intValue == $0.value } + }, object: state) + let result = XCTWaiter.wait(for: [ready], timeout: timeout) + let diagnostic = XCTAttachment(string: state.label) + diagnostic.name = "Synthetic MessageView lifecycle: " + phase + diagnostic.lifetime = .keepAlways + add(diagnostic) + let pixels = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + pixels.name = "Hosted MessageView: " + phase + pixels.lifetime = .keepAlways + add(pixels) + XCTAssertEqual(result, .completed, phase) + } + + func testOfflineEquationPixelsAndContainment() { + launchFixture() + app.buttons["renderer.equation-checks"].tap() + let complete = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "label == %@", "Equations 6/6; network blocked"), + object: app.staticTexts["renderer.equation-status"]) + XCTAssertEqual(XCTWaiter.wait(for: [complete], timeout: 35), .completed) + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Offline equations and positive-control containment canary" + attachment.lifetime = .keepAlways + add(attachment) + } + + func testRendererFixtureDisplaysNativeTextCodeAndTable() { + launchFixture() + let paragraph = app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Start alpha")).firstMatch + XCTAssertTrue(paragraph.waitForExistence(timeout: 15)) + XCTAssertTrue(app.buttons["Copy code"].waitForExistence(timeout: 10)) + app.buttons["Copy code"].tap() + XCTAssertTrue(app.buttons["Copied"].waitForExistence(timeout: 1)) + XCTAssertTrue(app.buttons["Copy code"].waitForExistence(timeout: 4)) + pasteSelection() + let expected = "let synthetic = \"copy code\" // preserved\nprint(synthetic)" + let copied = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "value == %@", expected), object: pasteField) + XCTAssertEqual(XCTWaiter.wait(for: [copied], timeout: 5), .completed) + let actual = pasteField.value as? String ?? "" + XCTAssertTrue(actual.contains("let synthetic")) + XCTAssertEqual(Array(actual.utf8), Array(expected.utf8)) + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Native renderer synthetic corpus" + attachment.lifetime = .keepAlways + add(attachment) + } + + func testNativeSelectionSurvivesAppend() { + launchFixture() + XCTAssertTrue(app.buttons["Copy code"].waitForExistence(timeout: 15)) + app.buttons["renderer.append"].tap() + let paragraph = app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Start alpha")).firstMatch + XCTAssertTrue(paragraph.waitForExistence(timeout: 15)) + paragraph.coordinate(withNormalizedOffset: CGVector(dx: 0.12, dy: 0.25)).press(forDuration: 1.2) + let copy = app.menuItems["Copy"].firstMatch + let copyButton = app.buttons["Copy"].firstMatch + XCTAssertTrue(copy.waitForExistence(timeout: 5) || copyButton.waitForExistence(timeout: 3)) + XCTAssertEqual(app.staticTexts["renderer.revision"].label, "Revision 0") + // Wait on an observable streamed append, leaving native selection active. + let revised = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "label == %@", "Revision 1"), + object: app.staticTexts["renderer.revision"]) + XCTAssertEqual(XCTWaiter.wait(for: [revised], timeout: 15), .completed) + let preserved = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "label == %@", "Selection preserved"), + object: app.staticTexts["renderer.selection-status"]) + XCTAssertEqual(XCTWaiter.wait(for: [preserved], timeout: 5), .completed) + if copy.exists { + copy.tap() + } else if copyButton.exists { + copyButton.tap() + } else { + paragraph.typeKey("c", modifierFlags: .command) + } + pasteSelection() + XCTAssertFalse((pasteField.value as? String ?? "").isEmpty) + } + + func testNativePartialSelectionControl() { + launchFixture() + XCTAssertTrue(app.buttons["Copy code"].waitForExistence(timeout: 15)) + let paragraph = app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Start alpha")).firstMatch + paragraph.coordinate(withNormalizedOffset: CGVector(dx: 0.12, dy: 0.25)).press(forDuration: 1.2) + let copy = app.menuItems["Copy"].firstMatch + let copyButton = app.buttons["Copy"].firstMatch + XCTAssertTrue(copy.waitForExistence(timeout: 5) || copyButton.waitForExistence(timeout: 3)) + if copy.exists { copy.tap() } else { copyButton.tap() } + pasteSelection() + XCTAssertFalse((pasteField.value as? String ?? "").isEmpty) + } + + func testEquationSourcesReachNativeAccessibility() { + launchFixture() + XCTAssertTrue(app.buttons["Copy code"].waitForExistence(timeout: 15)) + // Poll a content-free native probe, allowing bounded equation enrichment. + for _ in 0..<5 { + app.buttons["renderer.ax-check"].tap() + if app.staticTexts["renderer.ax-status"].label == "Equation AX present" { return } + } + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Native equation accessibility diagnostic counts" + attachment.lifetime = .keepAlways + add(attachment) + XCTFail( + "Required equation sources did not reach native accessibility custom content; " + + app.staticTexts["renderer.ax-counts"].label) + } + + func testWideTableScrollPreservesVerticalReading() { + continueAfterFailure = false + app = XCUIApplication() + app.launchArguments = ["--maple-renderer-fixture", "--maple-renderer-layout"] + app.launch() + let first = app.staticTexts["table first marker"] + XCTAssertTrue(first.waitForExistence(timeout: 15)) + let initial = first.frame.minX + let tableY = first.frame.midY / app.frame.height + app.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: tableY)).press( + forDuration: 0.1, + thenDragTo: app.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: tableY))) + XCTAssertLessThan(first.frame.minX, initial - 20) + let after = app.staticTexts["After wide table reading anchor."] + for _ in 0..<5 where !after.isHittable { app.swipeUp() } + XCTAssertTrue(after.isHittable) + let screenshot = XCTAttachment(screenshot: app.screenshot()) + screenshot.name = "Wide table horizontal and vertical native gestures" + screenshot.lifetime = .keepAlways + add(screenshot) + } + + func testLargeBoldDarkRTLRendererRemainsReadable() { + continueAfterFailure = false + app = XCUIApplication() + app.launchArguments = [ + "--maple-renderer-fixture", "--maple-renderer-layout", "--maple-renderer-large", + "--maple-renderer-dark", "--maple-renderer-rtl", + ] + app.launch() + let first = app.staticTexts["table first marker"] + XCTAssertTrue(first.waitForExistence(timeout: 15)) + // This short three-word cell must not become a single-character vertical + // column when Dynamic Type increases the font and cell padding. + XCTAssertLessThan(first.frame.height, first.frame.width) + let header = app.staticTexts["Primary choice"] + XCTAssertTrue(header.exists) + XCTAssertLessThan(header.frame.height, header.frame.width * 0.75) + let top = XCTAttachment(screenshot: app.screenshot()) + top.name = "Large Bold Text dark RTL table" + top.lifetime = .keepAlways + add(top) + let end = app.staticTexts["Final reading anchor after code and equation."] + for _ in 0..<12 where !end.isHittable { app.swipeUp() } + XCTAssertTrue(end.isHittable) + let bottom = XCTAttachment(screenshot: app.screenshot()) + bottom.name = "Large Bold Text dark RTL code and math" + bottom.lifetime = .keepAlways + add(bottom) + } + + func testLargeRTLCodeHeaderAndHorizontalReach() { + continueAfterFailure = false + app = XCUIApplication() + app.launchArguments = [ + "--maple-renderer-fixture", "--maple-renderer-layout", "--maple-renderer-large", + "--maple-renderer-dark", "--maple-renderer-rtl", + ] + app.launch() + let copy = app.buttons["Copy code"] + XCTAssertTrue(copy.waitForExistence(timeout: 15)) + for _ in 0..<12 where !copy.isHittable { app.swipeUp() } + XCTAssertTrue(copy.isHittable) + let code = app.staticTexts.containing( + NSPredicate(format: "label CONTAINS %@", "unbroken_long_identifier_0123456789") + ).firstMatch + let scroll = app.scrollViews["message.code.scroll"] + XCTAssertTrue(code.exists && scroll.exists) + XCTAssertGreaterThanOrEqual(copy.frame.height, 44) + XCTAssertGreaterThanOrEqual(copy.frame.width, 44) + XCTAssertLessThanOrEqual(copy.frame.maxY + 4, code.frame.minY) + XCTAssertGreaterThan(code.frame.width, scroll.frame.width + 20) + let initialX = code.frame.minX + let headerY = copy.frame.minY + let surrounding = [ + "After wide table reading anchor.", + "Final reading anchor after code and equation.", + ].map { fragment in + app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", fragment)).firstMatch + } + for anchor in surrounding { XCTAssertTrue(anchor.exists && anchor.isHittable) } + let surroundingFrames = surrounding.map(\.frame) + func verifySurrounding(_ phase: String) { + // Observe the actual surrounding native geometry after the horizontal + // gesture. This both synchronizes against a concrete UI invariant and + // distinguishes displaced body content from a screenshot-only anomaly. + let settled = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + zip(surrounding, surroundingFrames).allSatisfy { anchor, original in + guard anchor.exists && anchor.isHittable else { return false } + let current = anchor.frame + return abs(current.minX - original.minX) < 1 + && abs(current.minY - original.minY) < 1 + && abs(current.width - original.width) < 1 + && abs(current.height - original.height) < 1 + } + }, object: app) + let result = XCTWaiter.wait(for: [settled], timeout: 5) + let geometry = zip(surrounding, surroundingFrames).enumerated().map { index, pair in + "anchor\(index) before=\(pair.1) after=\(pair.0.frame) hittable=\(pair.0.isHittable)" + }.joined(separator: "\n") + let diagnostic = XCTAttachment( + string: "\(phase); app=\(app.frame); code=\(code.frame); scroll=\(scroll.frame)\n" + geometry) + diagnostic.name = "Synthetic surrounding renderer geometry \(phase)" + diagnostic.lifetime = .keepAlways; add(diagnostic) + let synchronized = XCTAttachment(screenshot: app.screenshot()) + synchronized.name = "Surrounding prose after observed geometry \(phase)" + synchronized.lifetime = .keepAlways; add(synchronized) + XCTAssertEqual(result, .completed, "Horizontal code scrolling must retain the surrounding body geometry") + } + // The RTL viewport starts at the trailing edge. Reach both physical + // ends with native gestures; code bytes and bidi policy stay unchanged. + for _ in 0..<12 where code.frame.minX < scroll.frame.minX - 2 { + scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.5)).press( + forDuration: 0.05, + thenDragTo: scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5))) + } + XCTAssertGreaterThan(code.frame.minX, initialX + 20) + XCTAssertGreaterThanOrEqual(code.frame.minX, scroll.frame.minX - 2) + XCTAssertEqual(copy.frame.minY, headerY, accuracy: 1) + let leading = XCTAttachment(screenshot: app.screenshot()) + leading.name = "Large RTL code leading edge and measured header" + leading.lifetime = .keepAlways; add(leading) + verifySurrounding("leading") + for _ in 0..<12 where code.frame.maxX > scroll.frame.maxX + 2 { + scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5)).press( + forDuration: 0.05, + thenDragTo: scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.5))) + } + XCTAssertLessThanOrEqual(code.frame.maxX, scroll.frame.maxX + 2) + XCTAssertEqual(copy.frame.minY, headerY, accuracy: 1) + let trailing = XCTAttachment(screenshot: app.screenshot()) + trailing.name = "Large RTL code trailing edge and measured header" + trailing.lifetime = .keepAlways; add(trailing) + verifySurrounding("trailing") + } + + func testMixedMarkdownPartialCopyPreservesLineBreaks() { + continueAfterFailure = false + app = XCUIApplication() + app.launchArguments = ["--maple-renderer-fixture", "--maple-renderer-corpus"] + app.launch() + XCTAssertTrue(app.buttons["renderer.copy-lines"].waitForExistence(timeout: 15)) + XCTAssertTrue( + app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Soft first")) + .firstMatch.waitForExistence(timeout: 10)) + // The initial plain Text fallback also contains these words. The actual + // prepared code control proves StructuredText owns this Copy operation. + XCTAssertTrue(app.buttons["Copy code"].firstMatch.waitForExistence(timeout: 10)) + waitForNativeCopySpan("renderer.copy-lines") + app.buttons["renderer.copy-lines"].tap() + let shot = XCTAttachment(screenshot: app.screenshot()) + shot.name = "Synthetic GFM soft and hard line-break copy probe" + shot.lifetime = .keepAlways + add(shot) + let diagnostic = XCTAttachment( + string: app.staticTexts["renderer.line-diagnostics"].label + "\n" + + app.staticTexts["renderer.copy-scalars"].label) + diagnostic.name = "Synthetic mixed-line selected and copied native scalar diagnostics" + diagnostic.lifetime = .keepAlways; add(diagnostic) + XCTAssertEqual(app.staticTexts["renderer.line-status"].label, "Soft newline 1; hard newline 1") + XCTAssertEqual( + app.staticTexts["renderer.representation-status"].label, "Canonical LF and raw UTF8 preserved") + } + + func testFullMarkdownCorpusRemainsVisibleThroughLowerBlocks() { + continueAfterFailure = false + app = XCUIApplication() + app.launchArguments = ["--maple-renderer-fixture", "--maple-renderer-corpus"] + app.launch() + XCTAssertTrue(app.buttons["renderer.copy-lines"].waitForExistence(timeout: 15)) + let scroll = app.scrollViews["renderer.corpus-scroll"] + XCTAssertTrue(scroll.exists) + + func reveal(_ fragment: String) -> XCUIElement { + let text = app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", fragment)).firstMatch + func visible() -> Bool { + guard text.exists else { return false } + let overlap = text.frame.intersection(scroll.frame) + return !overlap.isNull && overlap.height >= min(30, text.frame.height) && overlap.width > 0 + } + for _ in 0..<20 where !visible() { + // Native selection overlays can own the hit point of readable + // Text. Inspect actual visible bounds and use small directed drags. + let downward = text.exists && text.frame.midY < scroll.frame.midY + let startY = downward ? 0.35 : 0.65 + let endY = downward ? 0.65 : 0.35 + scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: startY)).press( + forDuration: 0.05, + thenDragTo: scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: endY))) + } + XCTAssertTrue(text.exists, "Missing synthetic corpus text: \(fragment)") + XCTAssertTrue(visible(), "Unreachable synthetic corpus text: \(fragment)") + return text + } + func record(_ name: String) { + let shot = XCTAttachment(screenshot: app.screenshot()) + shot.name = name; shot.lifetime = .keepAlways; add(shot) + } + + _ = reveal("Soft first") + record("Full corpus top: separate soft and hard lines, heading hierarchy and emphasis") + _ = reveal("Completed task marker") + record("Full corpus lists: nested bullets, static task checkboxes and ordered numbering") + _ = reveal("Indented ordinary prose") + XCTAssertEqual( + app.buttons.matching(identifier: "Copy code").count, 1, + "Only the explicit Swift fence is code; indented ordinary prose must remain prose") + record("Full corpus middle: quotation and normalized indented prose") + _ = reveal("reference link") + _ = reveal("Preserved remote image alt") + record("Full corpus links, untrusted HTML and remote-image fallback") + _ = reveal("print(synthetic)") + XCTAssertTrue(app.buttons["Copy code"].exists) + record("Full corpus fenced source retains code formatting and copy control") + _ = reveal("Incomplete original source") + record("Full corpus lower: multilingual text, matrix, cases and original malformed math") + XCTAssertTrue( + app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "unsupportedcommand")).firstMatch.exists + ) + } + + func testMultilingualNativePartialCopyPreservesOriginalBytes() { + continueAfterFailure = false + app = XCUIApplication() + app.launchArguments = ["--maple-renderer-fixture", "--maple-renderer-corpus"] + app.launch() + XCTAssertTrue(app.buttons["renderer.copy-multilingual"].waitForExistence(timeout: 15)) + XCTAssertTrue(app.buttons["Copy code"].firstMatch.waitForExistence(timeout: 10)) + waitForNativeCopySpan("renderer.copy-multilingual") + app.buttons["renderer.copy-multilingual"].tap() + let diagnostic = XCTAttachment( + string: app.staticTexts["renderer.line-diagnostics"].label + + "\n" + app.staticTexts["renderer.copy-scalars"].label) + diagnostic.name = "Synthetic multilingual native selected Copy bytes" + diagnostic.lifetime = .keepAlways; add(diagnostic) + XCTAssertEqual(app.staticTexts["renderer.multilingual-status"].label, "Multilingual bytes preserved") + XCTAssertEqual(app.staticTexts["renderer.representation-status"].label, "Raw UTF8 preserved") + // The status above compares the actual selected native substring with this + // synthetic corpus value byte-for-byte before leaving the selection fixture. + let expected = "العربية עברית 中文 👩🏽‍💻 e\u{301}." + app.terminate() + launchFixture() + // Reuse the existing native editor and Paste menu, without rewriting the + // clipboard, assigning editor text, or applying a test-only expiration. + pasteSelection() + let pasted = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "value == %@", expected), object: pasteField) + XCTAssertEqual(XCTWaiter.wait(for: [pasted], timeout: 5), .completed) + XCTAssertEqual(Array((pasteField.value as? String ?? "").utf8), Array(expected.utf8)) + } + + private func waitForNativeCopySpan(_ buttonID: String) { + let button = app.buttons[buttonID] + let ready = XCTNSPredicateExpectation(predicate: NSPredicate(format: "enabled == true"), object: button) + let result = XCTWaiter.wait(for: [ready], timeout: 20) + let diagnostic = XCTAttachment(string: app.staticTexts["renderer.copy-readiness"].label) + diagnostic.name = "Synthetic native span readiness before first Copy" + diagnostic.lifetime = .keepAlways; add(diagnostic) + XCTAssertEqual(result, .completed, "Copy requires the actual matching native input span") + } + + private var pasteField: XCUIElement { + // SwiftUI's vertical TextField can change native accessibility element + // type after pasting multiple lines. Keep the locator independent of type. + app.descendants(matching: .any).matching(identifier: "renderer.paste").firstMatch + } + + private func pasteSelection() { + pasteField.tap() + let tip = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", "Speed up your typing")) + .firstMatch + if tip.waitForExistence(timeout: 2), app.buttons["Continue"].exists { app.buttons["Continue"].tap() } + pasteField.press(forDuration: 1.0) + let paste = app.menuItems["Paste"].firstMatch + let pasteButton = app.buttons["Paste"].firstMatch + XCTAssertTrue(paste.waitForExistence(timeout: 3) || pasteButton.waitForExistence(timeout: 3)) + if paste.exists { paste.tap() } else { pasteButton.tap() } + } +} + +@MainActor +final class ComposerKeyboardUITests: XCTestCase { + private var app: XCUIApplication! + private var field: XCUIElement { + let view = app.textViews["composer.probe"] + return view.exists ? view : app.textFields["composer.probe"] + } + + private func launchFixture() { + continueAfterFailure = false + app = XCUIApplication() + app.launchArguments = ["--maple-composer-fixture"] + app.launch() + XCTAssertTrue(app.staticTexts["composer.sends"].waitForExistence(timeout: 15)) + dismissKeyboardTip() + } + + func testHardwareReturnSendsAndShiftReturnInsertsNewline() { + launchFixture() + field.tap() + field.typeText("synthetic") + field.typeKey(.return, modifierFlags: .shift) + XCTAssertEqual(app.staticTexts["composer.sends"].label, "Sends 0") + XCTAssertEqual(app.staticTexts["composer.newlines"].label, "Newlines 1") + field.typeText("second line") + field.typeKey(.return, modifierFlags: []) + XCTAssertEqual(app.staticTexts["composer.sends"].label, "Sends 1") + XCTAssertFalse(app.keyboards.firstMatch.exists) + } + + private func launchNativeControlFixture() { + continueAfterFailure = false + app = XCUIApplication() + app.launchArguments = ["--maple-composer-fixture", "--maple-native-keyboard-control"] + app.launch() + let control = app.textViews["composer.native-control"] + XCTAssertTrue(control.waitForExistence(timeout: 15)) + control.tap() + dismissKeyboardTip() + } + + /// The plain control must establish real modifier delivery and release before + /// the separately selected owned-composer case is admitted on this artifact. + func testHeldShiftNativeReturnDeliveryAndRelease() throws { + launchNativeControlFixture() + let control = app.textViews["composer.native-control"] + control.typeText("abc") + XCTAssertEqual(control.value as? String, "abc") + XCUIElement.perform(withKeyModifiers: .shift) { + control.typeKey(.return, modifierFlags: .shift) + } + recordNativeState(control) + XCTAssertEqual(try nativeReturnMasks(), [131_072]) + XCTAssertEqual(control.value as? String, "abc\n") + XCTAssertEqual(app.staticTexts["composer.native-newlines"].label, "Native newlines 1") + + // This key is outside the held block: its native mask proves release. + control.typeKey(.return, modifierFlags: []) + recordNativeState(control) + XCTAssertEqual(try nativeReturnMasks(), [131_072, 0]) + XCTAssertEqual(control.value as? String, "abc\n\n") + XCTAssertEqual(app.staticTexts["composer.native-newlines"].label, "Native newlines 2") + } + + private func nativeReturnMasks() throws -> [Int] { + let diagnostics = app.staticTexts["composer.native-diagnostics"].label + let fields = diagnostics.components(separatedBy: "; ") + XCTAssertTrue(fields.contains("scopeDropped 0")) + XCTAssertTrue(fields.contains("lifetimeDropped 0")) + let marker = "lifetime[ordinal:kind:hid:mask:phase:ended:cancelled:matched] " + let start = try XCTUnwrap(diagnostics.range(of: marker)) + let events = diagnostics[start.upperBound...].split(separator: ";") + XCTAssertFalse(events.isEmpty) + XCTAssertLessThanOrEqual(events.count, 64) + var masks: [Int] = [] + for (index, event) in events.enumerated() { + let parts = event.split(separator: ":", omittingEmptySubsequences: false) + let eightParts = try XCTUnwrap(parts.count == 8 ? parts : nil) + let values = try eightParts.map { try XCTUnwrap(Int($0)) } + XCTAssertEqual(values[0], index + 1) + if values[1] == 1 { + XCTAssertEqual(values[2], 40) + XCTAssertEqual(values[7], 1) + masks.append(values[3]) + } + } + return masks + } + + func testNativeUnmodifiedReturnKeyInjectionControl() { + launchNativeControlFixture() + let control = app.textViews["composer.native-control"] + control.typeText("native") + control.typeKey(.return, modifierFlags: []) + recordNativeState(control) + XCTAssertEqual(app.staticTexts["composer.native-newlines"].label, "Native newlines 1") + } + + /// A distinct documented physical key diagnoses delivery; it does not + /// substitute for the separate Return/ShiftReturn acceptance cases. + func testNativeEnterKeyInjectionControl() { + launchNativeControlFixture() + let control = app.textViews["composer.native-control"] + control.typeText("native") + control.typeKey("x", modifierFlags: []) + XCTAssertEqual((control.value as? String)?.lowercased(), "nativex") + control.typeKey(.enter, modifierFlags: []) + recordNativeState(control) + XCTAssertEqual(app.staticTexts["composer.native-newlines"].label, "Native newlines 1") + } + + func testNativeLiteralUnmodifiedReturnKeyInjectionControl() { + launchNativeControlFixture() + let control = app.textViews["composer.native-control"] + control.typeText("native") + control.typeKey("\r", modifierFlags: []) + recordNativeState(control) + XCTAssertEqual(app.staticTexts["composer.native-newlines"].label, "Native newlines 1") + } + + func testNativeHardwareKeyInjectionControl() { + launchNativeControlFixture() + let control = app.textViews["composer.native-control"] + control.tap() + control.typeText("native") + control.typeKey("x", modifierFlags: []) + XCTAssertEqual((control.value as? String)?.lowercased(), "nativex") + control.typeKey(.return, modifierFlags: .shift) + recordNativeState(control) + XCTAssertEqual(app.staticTexts["composer.native-newlines"].label, "Native newlines 1") + control.typeKey(.return, modifierFlags: []) + XCTAssertEqual(app.staticTexts["composer.native-newlines"].label, "Native newlines 2") + } + + func testNativeLiteralReturnKeyInjectionControl() { + launchNativeControlFixture() + let control = app.textViews["composer.native-control"] + control.tap() + control.typeText("native") + control.typeKey("x", modifierFlags: []) + XCTAssertEqual((control.value as? String)?.lowercased(), "nativex") + control.typeKey("\r", modifierFlags: .shift) + recordNativeState(control) + XCTAssertEqual(app.staticTexts["composer.native-newlines"].label, "Native newlines 1") + control.typeKey("\r", modifierFlags: []) + XCTAssertEqual(app.staticTexts["composer.native-newlines"].label, "Native newlines 2") + } + + private func recordNativeState(_ control: XCUIElement) { + let value = control.value as? String ?? "" + let scalars = value.unicodeScalars.map { String($0.value, radix: 16) }.joined(separator: ",") + let diagnostic = XCTAttachment( + string: "AX scalars " + scalars + "\n" + + app.staticTexts["composer.native-diagnostics"].label) + diagnostic.name = "Synthetic native key delivery and text state" + diagnostic.lifetime = .keepAlways; add(diagnostic) + } + + func testNativeSoftwareReturnControl() { + launchNativeControlFixture() + let control = app.textViews["composer.native-control"] + control.typeText("native") + let key = app.keyboards.buttons.matching(NSPredicate(format: "label ==[c] %@", "return")).firstMatch + XCTAssertTrue(key.waitForExistence(timeout: 5)) + key.tap() + recordNativeState(control) + XCTAssertEqual(app.staticTexts["composer.native-newlines"].label, "Native newlines 1") + } + + func testSwiftUIHardwareKeyInjectionControl() { + launchFixture() + field.tap() + field.typeText("synthetic") + field.typeKey("x", modifierFlags: []) + XCTAssertEqual((field.value as? String)?.lowercased(), "syntheticx") + } + + func testPlainHardwareReturnSends() { + launchFixture() + field.tap() + field.typeText("synthetic") + field.typeKey(.return, modifierFlags: []) + XCTAssertEqual(app.staticTexts["composer.sends"].label, "Sends 1") + XCTAssertFalse(app.keyboards.firstMatch.exists) + } + + func testSoftwareReturnInsertsNewline() { + launchFixture() + XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 10)) + field.typeText("synthetic") + let lower = app.keyboards.buttons["return"] + let upper = app.keyboards.buttons["Return"] + XCTAssertTrue(lower.exists || upper.exists) + if lower.exists { lower.tap() } else { upper.tap() } + XCTAssertEqual(app.staticTexts["composer.sends"].label, "Sends 0") + XCTAssertEqual(app.staticTexts["composer.newlines"].label, "Newlines 1") + } + + func testHardwareReturnDoesNotSendMarkedComposition() { + launchFixture() + field.tap() + field.typeText("prefix ") + app.buttons["composer.mark"].tap() + XCTAssertEqual(app.staticTexts["composer.marked"].label, "Marked 1") + field.typeKey(.return, modifierFlags: []) + XCTAssertEqual(app.staticTexts["composer.sends"].label, "Sends 0") + app.buttons["composer.read-mark"].tap() + XCTAssertEqual(app.staticTexts["composer.marked"].label, "Marked 0") + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "SwiftUI controlled marked-text keyboard probe" + attachment.lifetime = .keepAlways + add(attachment) + } + + private func dismissKeyboardTip() { + let tip = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", "Speed up your typing")) + .firstMatch + if tip.waitForExistence(timeout: 2), app.buttons["Continue"].exists { app.buttons["Continue"].tap() } + } +} + +@MainActor +final class OwnedComposerUITests: XCTestCase { + private var app: XCUIApplication! + private var field: XCUIElement { app.textViews["composer.probe"] } + + private func launchFixture() { + continueAfterFailure = false + app = XCUIApplication() + app.launchArguments = ["--maple-composer-fixture", "--maple-owned-composer"] + app.launch() + XCTAssertTrue(field.waitForExistence(timeout: 15)) + field.tap() + let tip = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", "Speed up your typing")) + .firstMatch + if tip.waitForExistence(timeout: 2), app.buttons["Continue"].exists { app.buttons["Continue"].tap() } + } + + func testHardwareReturnAndShiftReturn() { + launchFixture() + field.typeText("synthetic") + field.typeKey(.return, modifierFlags: .shift) + XCTAssertEqual(app.staticTexts["composer.sends"].label, "Sends 0") + XCTAssertEqual(app.staticTexts["composer.newlines"].label, "Newlines 1") + field.typeText("second") + field.typeKey(.return, modifierFlags: []) + XCTAssertEqual(app.staticTexts["composer.sends"].label, "Sends 1") + XCTAssertFalse(app.keyboards.firstMatch.exists) + } + + /// Run only after the same artifact's plain-native held-modifier control passes. + func testHeldShiftReturnProducesDistinctNewlines() throws { + continueAfterFailure = false + app = XCUIApplication() + app.launchArguments = [ + "--maple-composer-fixture", "--maple-owned-composer", "--maple-composer-order-events", + ] + app.launch() + XCTAssertTrue(field.waitForExistence(timeout: 15)) + field.tap() + let tip = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", "Speed up your typing")) + .firstMatch + if tip.waitForExistence(timeout: 2), app.buttons["Continue"].exists { app.buttons["Continue"].tap() } + field.typeText("abc") + XCTAssertEqual(field.value as? String, "abc") + app.buttons["composer.order-record"].tap() + XCTAssertEqual(app.staticTexts["composer.order-events"].label, "Recording synthetic input order") + + XCUIElement.perform(withKeyModifiers: .shift) { + self.field.typeKey(.return, modifierFlags: .shift) + } + let firstText = field.value as? String + let firstNewlines = app.staticTexts["composer.newlines"].label + let firstSends = app.staticTexts["composer.sends"].label + let firstEdits = app.staticTexts["composer.edit-summary"].label + if firstText != "abc\n" || firstNewlines != "Newlines 1" || firstSends != "Sends 0" + || !firstEdits.hasSuffix("invalid 0") + { + _ = readHeldModifierDiagnostics() + } + XCTAssertEqual(firstText, "abc\n") + XCTAssertEqual(firstNewlines, "Newlines 1") + XCTAssertEqual(firstSends, "Sends 0") + XCTAssertTrue(firstEdits.hasSuffix("invalid 0")) + + // Two distinct key events in one held context must produce two more LFs. + XCUIElement.perform(withKeyModifiers: .shift) { + self.field.typeKey(.return, modifierFlags: .shift) + self.field.typeKey(.return, modifierFlags: .shift) + } + let diagnostics = readHeldModifierDiagnostics() + XCTAssertEqual(field.value as? String, "abc\n\n\n") + XCTAssertEqual(app.staticTexts["composer.newlines"].label, "Newlines 3") + XCTAssertEqual(app.staticTexts["composer.sends"].label, "Sends 0") + XCTAssertTrue(app.staticTexts["composer.edit-summary"].label.hasSuffix("invalid 0")) + let lines = diagnostics.split(separator: "\n") + let parts = try XCTUnwrap(lines.first).components(separatedBy: "; ") + let header = try XCTUnwrap(parts.count == 3 ? parts : nil) + XCTAssertEqual(header[0], "Composer input diagnostics v1") + XCTAssertTrue(header[1].hasPrefix("recorded=")) + let count = try XCTUnwrap(Int(header[1].dropFirst("recorded=".count))) + XCTAssertEqual(header[2], "dropped=0") + let events = Array(lines.dropFirst()) + XCTAssertEqual(events.count, count) + XCTAssertGreaterThan(count, 0) + XCTAssertLessThanOrEqual(count, 256) + for (index, event) in events.enumerated() { + XCTAssertTrue(event.hasPrefix("\(index + 1): ")) + } + let commands = events.filter { $0.contains(": command.entry;") } + XCTAssertEqual(commands.count, 3) + for command in commands { + XCTAssertTrue(command.contains("; kind=newline; modifierMask=131072; ownerAccepted=true; eligible=true;")) + } + XCTAssertEqual(events.filter { $0.contains(": command.exit;") }.count, 3) + XCTAssertFalse(events.contains { $0.contains("; kind=submit;") || $0.contains(": submit.entry;") }) + XCTAssertFalse(events.contains { $0.contains("; overflow=true;") }) + } + + private func readHeldModifierDiagnostics() -> String { + app.buttons["composer.order-read"].tap() + let value = app.staticTexts["composer.order-events"].label + let diagnostic = XCTAttachment(string: value) + diagnostic.name = "Synthetic held Shift command receipt" + diagnostic.lifetime = .keepAlways + add(diagnostic) + let screenshot = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + screenshot.name = "Synthetic held Shift newline state" + screenshot.lifetime = .keepAlways + add(screenshot) + return value + } + + func testLiteralHardwareReturnAndShiftReturn() { + launchFixture() + field.typeText("synthetic") + field.typeKey("\r", modifierFlags: .shift) + XCTAssertEqual(app.staticTexts["composer.sends"].label, "Sends 0") + XCTAssertEqual(app.staticTexts["composer.newlines"].label, "Newlines 1") + field.typeText("second") + field.typeKey("\r", modifierFlags: []) + XCTAssertEqual(app.staticTexts["composer.sends"].label, "Sends 1") + XCTAssertFalse(app.keyboards.firstMatch.exists) + } + + func testSoftwareReturnRemainsNewline() { + launchFixture() + field.typeText("synthetic") + let key = + app.keyboards.buttons["return"].exists ? app.keyboards.buttons["return"] : app.keyboards.buttons["Return"] + XCTAssertTrue(key.exists) + key.tap() + XCTAssertEqual(app.staticTexts["composer.sends"].label, "Sends 0") + XCTAssertEqual(app.staticTexts["composer.newlines"].label, "Newlines 1") + } + + func testActualTapRestoresSoftwareKeyboardAfterOwnedCommand() { + continueAfterFailure = false + app = XCUIApplication() + app.launchArguments = [ + "--maple-composer-fixture", "--maple-owned-composer", "--maple-composer-presentation-check", + ] + app.launch() + XCTAssertTrue(field.waitForExistence(timeout: 15)) + field.tap() + let tip = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", "Speed up your typing")) + .firstMatch + if tip.waitForExistence(timeout: 2), app.buttons["Continue"].exists { app.buttons["Continue"].tap() } + let softwareReturn = app.keyboards.buttons.matching( + NSPredicate(format: "label ==[c] %@ OR identifier ==[c] %@", "return", "return") + ).firstMatch + XCTAssertTrue(softwareReturn.waitForExistence(timeout: 10)) + field.typeText("abc") + XCTAssertEqual(field.value as? String, "abc") + let setup = app.buttons["composer.presentation-send"] + XCTAssertTrue(setup.isHittable) + setup.tap() + XCTAssertEqual(app.staticTexts["composer.presentation-status"].label, "Owned command dispatched") + let sent = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in self.app.staticTexts["composer.sends"].label == "Sends 1" }, object: nil) + XCTAssertEqual(XCTWaiter.wait(for: [sent], timeout: 10), .completed) + let hidden = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in !softwareReturn.exists }, object: nil) + XCTAssertEqual(XCTWaiter.wait(for: [hidden], timeout: 10), .completed) + let before = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + before.name = "Owned command dismissed software keys"; before.lifetime = .keepAlways; add(before) + + // This is an actual native tap, not a direct restoration-method call. + field.tap() + XCTAssertTrue(softwareReturn.waitForExistence(timeout: 10)) + XCTAssertTrue(softwareReturn.isHittable) + field.typeText("next") + XCTAssertEqual(field.value as? String, "next") + softwareReturn.tap() + XCTAssertEqual(field.value as? String, "next\n") + XCTAssertEqual(app.staticTexts["composer.newlines"].label, "Newlines 1") + XCTAssertEqual(app.staticTexts["composer.sends"].label, "Sends 1") + XCTAssertTrue(app.staticTexts["composer.edit-summary"].label.hasSuffix("invalid 0")) + let after = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + after.name = "Native tap restored software Return newline"; after.lifetime = .keepAlways; add(after) + } + + func testRepeatedTextExactDeletionAndCompositionEvents() { + launchFixture() + app.buttons["composer.repeated"].tap() + XCTAssertEqual(field.value as? String, "aaaa") + app.buttons["composer.delete-first"].tap() + XCTAssertEqual(field.value as? String, "aaa") + XCTAssertEqual(app.staticTexts["composer.edit-summary"].label, "Edit 0:1:0; invalid 0") + app.buttons["composer.mark"].tap() + XCTAssertEqual(app.staticTexts["composer.native-marked"].label, "Native marked 1") + XCTAssertEqual(app.staticTexts["composer.marked"].label, "Marked 1") + field.typeKey(.return, modifierFlags: []) + XCTAssertEqual(app.staticTexts["composer.sends"].label, "Sends 0") + app.buttons["composer.unmark"].tap() + XCTAssertEqual(app.staticTexts["composer.marked"].label, "Marked 0") + XCTAssertTrue(app.staticTexts["composer.edit-summary"].label.hasSuffix("invalid 0")) + app.buttons["composer.mark"].tap() + XCTAssertEqual(app.staticTexts["composer.native-marked"].label, "Native marked 1") + XCTAssertEqual(app.staticTexts["composer.marked"].label, "Marked 1") + app.buttons["composer.clear"].tap() + XCTAssertEqual(app.staticTexts["composer.marked"].label, "Marked 0") + XCTAssertEqual(app.staticTexts["composer.newlines"].label, "Newlines 0") + XCTAssertEqual(field.value as? String ?? "", "") + app.buttons["composer.mark"].tap() + XCTAssertEqual(app.staticTexts["composer.native-marked"].label, "Native marked 1") + XCTAssertEqual(app.staticTexts["composer.marked"].label, "Marked 1") + app.buttons["composer.switch-owner"].tap() + app.buttons["composer.read-previous-owner"].tap() + XCTAssertEqual(app.staticTexts["composer.previous-owner"].label, "Previous owner committed") + } + + func testReentrantOwnerChangeCannotRedirectStateOrSubmit() { + launchFixture() + app.buttons["composer.reentrant-check"].tap() + XCTAssertEqual(app.staticTexts["composer.reentrant-status"].label, "Owner callbacks fenced") + XCTAssertEqual(app.staticTexts["composer.sends"].label, "Sends 0") + } + + func testNativeComposerMinimumTapSurface() { + launchFixture() + XCTAssertTrue(field.isHittable) + XCTAssertGreaterThanOrEqual(field.frame.height, 44) + XCTAssertGreaterThanOrEqual(field.frame.width, 44) + let otherInput = app.textViews["composer.native-control"] + XCTAssertTrue(otherInput.exists && otherInput.isHittable) + otherInput.tap() + let otherValue = otherInput.value as? String ?? "" + field.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.9)).tap() + // Target the application, not the field: the coordinate tap must acquire + // input itself rather than typeText implicitly refocusing that field. + app.typeText("x") + XCTAssertEqual((field.value as? String ?? "").lowercased(), "x") + XCTAssertEqual(otherInput.value as? String ?? "", otherValue) + XCTAssertGreaterThanOrEqual(field.frame.height, 44) + let screenshot = XCTAttachment(screenshot: app.screenshot()) + screenshot.name = "Native composer owns its minimum input target" + screenshot.lifetime = .keepAlways + add(screenshot) + } + + func testProposedEditSurvivesPreMutationSelection() { + continueAfterFailure = false + app = XCUIApplication() + app.launchArguments = ["--maple-composer-fixture", "--maple-composer-order-check"] + app.launch() + let check = app.buttons["composer.order-check"] + XCTAssertTrue(check.waitForExistence(timeout: 15)) + XCTAssertTrue(check.isHittable) + check.tap() + let status = app.staticTexts["composer.order-status"] + let complete = XCTNSPredicateExpectation( + predicate: NSPredicate( + format: "label CONTAINS %@", "nativeImagePasteRoutesProvidersAndPreservesTextFallback:"), + object: status) + XCTAssertEqual(XCTWaiter.wait(for: [complete], timeout: 15), .completed) + let diagnostic = XCTAttachment(string: status.label) + diagnostic.name = "Controlled production Coordinator callback order" + diagnostic.lifetime = .keepAlways + add(diagnostic) + let expectedCases = [ + "proposedEditSurvivesPreMutationSelectionAndPublishesExactlyOnce", + "sameTextReplacementWaitsForCompletionThenCarriesExactIntent", + "rejectedProposalDoesNotCreateAnEditOrPoisonTheNextKnownEdit", + "unmatchedActualMutationStillFailsClosed", + "mutationWithoutAProposalStillFailsClosed", + "oldOwnerFlushRetainsExactEditAndRoutesItOnlyToOldOwner", + "overflowingActualInputStillFailsClosed", + "ownedInputCompletionPublishesAfterNestedCallbacks", + "authoritativeSameOwnerUpdateRetiresPendingProposal", + "resignationIncludesPriorInputBeforeSubmit", + "resignationOrdersPriorInputBeforeShiftNewline", + "hardwareReturnAndKeypadCommandsUseSameGuardedPath", + "ordinaryTextInputPreservesNewlinesWithoutSubmitting", + "markdownNewlineRetainsExactDictationAnchorAndComposition", + "markedInputDoesNotExposeOrExecuteReturnCommands", + "retiredOwnerCannotExecuteCachedReturnCommand", + "reentrantHardwareCommandCannotSubmitTwice", + "distinctShiftActionsEachInsertOneNewline", + "refusedResignationDoesNotSendOrClear", + "compositionDuringResignationCancelsWithoutCommit", + "revokedOwnerWithoutInstallationCannotSend", + "successorIsInstalledBeforeHardwareFocusResumes", + "replacementDuringResignationCancelsSend", + "nestedFocusLossDuringResignationCancelsSend", + "pasteDuringResignationCancelsSend", + "publicationRevocationCancelsHardwareSend", + "postReturnTextStaysWithAuthoritativeSuccessor", + "sameOwnerSubmitClearsBeforePendingNativeInsertion", + "rotatedSubmitRoutesPendingInputOnlyToSuccessor", + "rejectedAndReservedSubmitRetainCurrentDraft", + "reservedOwnerAppliesLaterAuthoritativeSuccessor", + "submitRefreshCannotReplaceReentrantOwner", + "submitRefreshRejectsNilAndDifferentAccount", + "postSubmitPreservesExactInputDuringNativeAssignment", + "postSubmitPreservesExactInputDuringFocusChange", + "postSubmitReplacementRevokesUnpublishedNativeInput", + "postSubmitUnknownNativeMutationKeepsMarkerFailure", + "postSubmitNestedRenderWithoutInputStillUpdates", + "postSubmitStaleOwnerRenderCannotReclaimSuccessor", + "postSubmitEqualBaselineRuleEndsAtOuterUnwind", + "ordinarySameOwnerInstallPreservesMarkedComposition", + "hardwareSendDismissalPreservesSuccessorKeyboardInput", + "keyboardPresentationPreservesSendDisposition", + "explicitEditingRestoresSystemKeyboardWithExactInput", + "ordinaryOwnerAndTeardownResetKeyboardPresentation", + "keyboardPresentationReentryCannotReclaimReplacement", + "keyboardRestorationPreservesMarkedAndRevokedOwners", + "compactInteractionReclaimsRetainedKeyboardFocus", + "compactInteractionSurvivesSameOwnerRender", + "compactInteractionStartsUnfocusedEditorWithExactInput", + "compactInteractionCannotReclaimRevokedPresentation", + "lateNativeFocusCallbacksCannotReopenOrDismissEditor", + "unrequestedNativeFocusCannotReopenDismissedComposer", + "nativeTextInsetsKeepCaretAndPlaceholderAligned", + "nativeImagePasteRoutesProvidersAndPreservesTextFallback", + ] + XCTAssertEqual(status.label, expectedCases.map { "\($0): passed" }.joined(separator: "\n")) + } + + func testBatchedEditsPreserveTheUnchangedMiddleMarker() { + launchFixture() + app.buttons["composer.repeated"].tap() + XCTAssertEqual(field.value as? String, "aaaa") + app.buttons["composer.batch"].tap() + XCTAssertEqual(field.value as? String, "baac") + XCTAssertEqual(app.staticTexts["composer.transactions"].label, "0:1:1|3:1:1") + XCTAssertEqual(app.staticTexts["composer.marker"].label, "Marker 2") + XCTAssertTrue(app.staticTexts["composer.edit-summary"].label.hasSuffix("invalid 0")) + } +} diff --git a/apps/maple-ios/UITests/ReplyPlayerUITests.swift b/apps/maple-ios/UITests/ReplyPlayerUITests.swift new file mode 100644 index 000000000..8d16470e7 --- /dev/null +++ b/apps/maple-ios/UITests/ReplyPlayerUITests.swift @@ -0,0 +1,110 @@ +import XCTest + +/// Uses the real native player and protected temporary files with synthetic audio. +/// This proves simulator behavior; physical routes and lock playback are separate. +@MainActor final class ReplyPlayerUITests: XCTestCase { + private var app: XCUIApplication! + + private func launch(reset: Bool) { + continueAfterFailure = false + app = XCUIApplication() + app.launchArguments = ["--maple-player-fixture"] + if reset { app.launchArguments.append("--maple-player-fixture-reset") } + app.launch() + label("player.fixture.ready", equals: "ready") + label("player.fixture.issue", equals: "none") + } + private func label(_ id: String, equals value: String, timeout: TimeInterval = 15) { + let element = app.staticTexts[id] + XCTAssertTrue(element.waitForExistence(timeout: timeout)) + let match = XCTNSPredicateExpectation(predicate: NSPredicate(format: "label == %@", value), object: element) + XCTAssertEqual(XCTWaiter.wait(for: [match], timeout: timeout), .completed) + } + private func tap(_ id: String) { + let button = app.buttons[id].firstMatch + XCTAssertTrue(button.waitForExistence(timeout: 10)) + for _ in 0..<8 where !button.isHittable { + let scroll = app.scrollViews.firstMatch + if button.frame.midY < app.frame.midY { scroll.swipeDown() } else { scroll.swipeUp() } + } + XCTAssertTrue(button.isHittable); button.tap() + } + private func autoplay(_ value: String) { + let control = app.switches["audio.autoplay"] + XCTAssertTrue(control.waitForExistence(timeout: 10)) + if control.value as? String != value { control.tap() } + XCTAssertEqual(control.value as? String, value) + } + + func testSavedSpeedAppliesAfterChatChangeAndToNewEligibleAutoplay() { + launch(reset: true); tap("player.fixture.play") + label("player.fixture.rate", equals: "1.0") + tap("audio.play-pause"); tap("audio.speed"); tap("1.5×") + tap("player.fixture.checkpoint"); label("player.fixture.saved", equals: "saved") + tap("audio.close"); label("player.fixture.bytes", equals: "0") + app.terminate(); launch(reset: false) + label("player.fixture.saved-speed", equals: "1.5") + XCTAssertFalse(app.buttons["audio.close"].exists) + tap("player.fixture.change-chat") + label("player.fixture.visible-chat", equals: "synthetic-other-chat") + tap("player.fixture.play"); label("player.fixture.rate", equals: "1.5") + XCTAssertEqual(app.switches["audio.autoplay"].value as? String, "0") + autoplay("1") + for _ in 0..<3 { tap("audio.forward") } + label("player.fixture.ended", equals: "ended") + tap("player.fixture.complete") + label("player.fixture.current", equals: "automatic-reply-1") + label("player.fixture.rate", equals: "1.5") + label("player.fixture.default-rate", equals: "1.5") + label("player.fixture.metadata-policy", equals: "Maple-only") + label("player.fixture.saved-speed", equals: "1.5") + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Native eligible autoplay uses saved speed after chat change and cold restart" + attachment.lifetime = .keepAlways; add(attachment) + tap("audio.close"); label("player.fixture.bytes", equals: "0") + label("player.fixture.metadata-policy", equals: "absent"); app.terminate() + } + + func testNativeTransportMetadataAndColdLaunchPreferenceBoundaries() { + launch(reset: true) + XCTAssertFalse(app.buttons["audio.close"].exists) + tap("player.fixture.play") + label("player.fixture.metadata-policy", equals: "Maple-only") + label("player.fixture.rate", equals: "1.0") + XCTAssertEqual(app.buttons["audio.play-pause"].label, "Pause reply audio") + tap("audio.play-pause") + label("player.fixture.rate", equals: "0.0") + XCTAssertEqual(app.buttons["audio.play-pause"].label, "Play reply audio") + // Paused, clamped ten-second seeks produce stable metadata observations. + tap("audio.back"); label("player.fixture.elapsed", equals: "0") + tap("audio.forward"); label("player.fixture.elapsed", equals: "10") + tap("audio.back"); label("player.fixture.elapsed", equals: "0") + tap("audio.speed"); tap("1.5×") + label("player.fixture.default-rate", equals: "1.5") + label("player.fixture.saved-speed", equals: "1.5") + autoplay("1") + tap("player.fixture.checkpoint"); label("player.fixture.saved", equals: "saved") + tap("audio.play-pause"); label("player.fixture.rate", equals: "1.5") + label("player.fixture.metadata-policy", equals: "Maple-only") + app.terminate() + + launch(reset: false) + label("player.fixture.saved-speed", equals: "1.5") + label("player.fixture.bytes", equals: "0") + label("player.fixture.metadata-policy", equals: "absent") + XCTAssertFalse(app.buttons["audio.close"].exists) + tap("player.fixture.play") + label("player.fixture.rate", equals: "1.5") + let automatic = app.switches["audio.autoplay"] + XCTAssertTrue(automatic.waitForExistence(timeout: 10)) + XCTAssertEqual(automatic.value as? String, "0", "A prior listening session must not restore autoplay") + tap("audio.close") + label("player.fixture.metadata-policy", equals: "absent") + label("player.fixture.bytes", equals: "0") + XCTAssertFalse(app.buttons["audio.close"].exists) + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Native audio closed with saved speed and no metadata or temporary files" + attachment.lifetime = .keepAlways; add(attachment) + app.terminate() + } +} diff --git a/apps/maple-ios/UITests/RootLifecycleUITests.swift b/apps/maple-ios/UITests/RootLifecycleUITests.swift new file mode 100644 index 000000000..a3b340fb9 --- /dev/null +++ b/apps/maple-ios/UITests/RootLifecycleUITests.swift @@ -0,0 +1,198 @@ +import XCTest + +/// Real local signed-in root with deterministic microphone/transcription/path +/// inputs. Account, navigation, draft persistence, composer and ownership are +/// the same implementation used by the ordinary local build. +@MainActor final class RootLifecycleUITests: XCTestCase { + private var app: XCUIApplication! + private var liveWebSearchOriginal: String? + + private func launch() throws { + try XCTSkipUnless(ProcessInfo.processInfo.environment["MAPLE_RUN_LOCAL_UI"] == "1") + continueAfterFailure = false + app = XCUIApplication(); app.launchArguments = ["--maple-local-lifecycle-fixture"]; app.launch() + XCTAssertTrue(app.buttons["chat.new"].waitForExistence(timeout: 30)) + XCTAssertTrue(app.buttons["lifecycle.connectivity"].waitForExistence(timeout: 10)) + app.buttons["chat.new"].tap() + } + private func replaceDraft(_ text: String) throws { + try replaceSyntheticText(app.textViews["chat.composer"], with: text, in: app) + } + private func waitForStops(_ count: Int) { + let match = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.app.staticTexts["lifecycle.capture-counts"].label.contains("stops \(count),") + }, object: app) + XCTAssertEqual(XCTWaiter.wait(for: [match], timeout: 15), .completed) + XCTAssertFalse(app.buttons["dictation.stop"].exists) + } + private func startCapture() { + let start = app.buttons["dictation.start"] + XCTAssertTrue(start.waitForExistence(timeout: 10)); XCTAssertTrue(start.isEnabled); start.tap() + XCTAssertTrue(app.buttons["dictation.stop"].waitForExistence(timeout: 10)) + } + private func completeTranscript() { + app.buttons["lifecycle.transcript"].tap() + let inserted = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + (self.app.textViews["chat.composer"].value as? String)?.contains("synthetic dictated text") == true + }, object: app) + XCTAssertEqual(XCTWaiter.wait(for: [inserted], timeout: 15), .completed) + XCTAssertFalse(app.buttons["dictation.discard"].exists) + } + + private func tapLiveControl(_ identifier: String) { + let button = identifier == "chat.menu" ? app.mapleMenuButton : app.buttons[identifier].firstMatch + XCTAssertTrue(button.waitForExistence(timeout: 15)) + XCTAssertTrue(button.isEnabled && button.isHittable, "Unavailable control: " + identifier) + button.tap() + } + + private func waitForLiveUI(_ condition: @escaping @MainActor () -> Bool, timeout: TimeInterval = 15) { + let expectation = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in condition() }, object: app) + XCTAssertEqual(XCTWaiter.wait(for: [expectation], timeout: timeout), .completed) + } + + private func restoreLiveWebSearchIfNeeded() { + guard let original = liveWebSearchOriginal else { return } + app.terminate(); app.launchArguments = []; app.launch() + guard app.buttons["chat.model"].waitForExistence(timeout: 30) else { + XCTFail("The local root was unavailable while restoring its preference"); return + } + tapLiveControl("chat.model") + let toggle = app.switches["model.web-search"] + guard toggle.waitForExistence(timeout: 15), toggle.isEnabled else { + XCTFail("The local web-search preference could not be restored"); return + } + if toggle.value as? String != original { tapNativeSwitch(toggle, in: app) } + waitForLiveUI { toggle.value as? String == original } + liveWebSearchOriginal = nil + } + + /// Uses the ordinary signed-in compact root, retained UIKit navigation and + /// real local services. It creates no chat/project and never sends a turn. + func testRootChatOptionsProjectNavigationAndMenuSearchHitTargets() throws { + try XCTSkipUnless(ProcessInfo.processInfo.environment["MAPLE_RUN_LOCAL_UI"] == "1") + continueAfterFailure = false + app = XCUIApplication(); app.launchArguments = []; app.launch() + XCTAssertTrue(app.buttons["chat.new"].waitForExistence(timeout: 30)) + tapLiveControl("chat.new") + XCTAssertEqual(app.buttons["chat.model"].label, "Chat options") + tapLiveControl("chat.model") + let project = app.buttons["chat-options.project"] + XCTAssertTrue(project.waitForExistence(timeout: 15) && project.isEnabled) + tapLiveControl("chat-options.project") + XCTAssertTrue(app.buttons["project-picker.project.none"].waitForExistence(timeout: 15)) + XCTAssertTrue(app.buttons["project-picker.new-project"].isEnabled) + let back = app.navigationBars.buttons.firstMatch + XCTAssertTrue(back.waitForExistence(timeout: 10) && back.isHittable); back.tap() + XCTAssertTrue(project.waitForExistence(timeout: 15) && project.isEnabled) + + let toggle = app.switches["model.web-search"] + XCTAssertTrue(toggle.waitForExistence(timeout: 15) && toggle.isEnabled) + let original = try XCTUnwrap(toggle.value as? String) + XCTAssertTrue(original == "0" || original == "1") + liveWebSearchOriginal = original + let restore: @MainActor @Sendable () -> Void = { [weak self] in self?.restoreLiveWebSearchIfNeeded() } + addTeardownBlock { await restore() } + tapNativeSwitch(toggle, in: app) + waitForLiveUI { toggle.value as? String == (original == "1" ? "0" : "1") } + tapNativeSwitch(toggle, in: app); waitForLiveUI { toggle.value as? String == original } + liveWebSearchOriginal = nil + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.2)).tap() + waitForLiveUI { !project.exists && self.app.buttons["chat.model"].isHittable } + + tapLiveControl("chat.menu") + let search = app.textFields["library.search"] + XCTAssertTrue(search.waitForExistence(timeout: 15) && search.isHittable) + // Enter near the field edge, then clear near the button edge rather + // than only testing the centered glyph's small visible footprint. + search.coordinate(withNormalizedOffset: CGVector(dx: 0.04, dy: 0.5)).tap() + XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 10)) + let query = "Native search no matching chat" + search.typeText(query) + waitForLiveUI { search.value as? String == query } + let clear = app.buttons["library.clear-search"] + XCTAssertTrue(clear.waitForExistence(timeout: 10)) + XCTAssertGreaterThanOrEqual(clear.frame.width, 44) + XCTAssertGreaterThanOrEqual(clear.frame.height, 44) + clear.coordinate(withNormalizedOffset: CGVector(dx: 0.1, dy: 0.5)).tap() + waitForLiveUI { + let value = search.value as? String + return value != nil && (value == "" || value == search.placeholderValue) + && !clear.exists && self.app.buttons["library.close"].isHittable + } + XCTAssertFalse(app.buttons["chat.model"].isHittable, "Clearing search must not open a chat behind it") + XCTAssertTrue(app.keyboards.firstMatch.exists) + XCTAssertLessThanOrEqual(search.frame.maxY, app.keyboards.firstMatch.frame.minY) + + tapLiveControl("library.close") + waitForLiveUI { self.app.buttons["chat.model"].isHittable && !self.app.keyboards.firstMatch.exists } + tapLiveControl("chat.menu") + waitForLiveUI { search.isHittable && self.app.buttons["library.close"].isHittable } + XCTAssertFalse(app.keyboards.firstMatch.exists, "A retained hidden menu must relinquish search focus") + search.tap() + XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 10)) + waitForLiveUI { search.frame.maxY <= self.app.keyboards.firstMatch.frame.minY } + tapLiveControl("library.close") + waitForLiveUI { self.app.buttons["chat.model"].isHittable && !self.app.keyboards.firstMatch.exists } + XCTAssertFalse(app.buttons["chat.stop"].exists, "Options and searching must never dispatch a turn") + let screenshot = XCTAttachment(screenshot: app.screenshot()) + screenshot.name = "Actual local root after options and search navigation" + screenshot.lifetime = .keepAlways; add(screenshot) + } + + func testRootOfflineAndReconnectPreserveUnsentDraftWithoutDispatch() throws { + try launch(); try replaceDraft("Explicit local offline draft stays unsent.") + app.buttons["lifecycle.connectivity"].tap() + XCTAssertTrue(app.staticTexts["Offline — your draft stays here"].waitForExistence(timeout: 10)) + XCTAssertFalse(app.buttons["chat.send"].isEnabled) + XCTAssertEqual(app.textViews["chat.composer"].value as? String, "Explicit local offline draft stays unsent.") + XCTAssertFalse(app.buttons["chat.stop"].exists) + app.buttons["lifecycle.connectivity"].tap() + let online = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + !self.app.staticTexts["Offline — your draft stays here"].exists + && self.app.buttons["chat.send"].isEnabled + }, object: app) + XCTAssertEqual(XCTWaiter.wait(for: [online], timeout: 15), .completed) + XCTAssertEqual(app.textViews["chat.composer"].value as? String, "Explicit local offline draft stays unsent.") + XCTAssertFalse(app.buttons["chat.stop"].exists) + app.mapleMenuButton.tap(); app.buttons["library.close"].tap() + XCTAssertEqual(app.textViews["chat.composer"].value as? String, "Explicit local offline draft stays unsent.") + } + + func testRootMenuChatNavigationAndBackgroundStopCapture() throws { + try launch(); try replaceDraft("Menu capture owner. ") + startCapture(); app.mapleMenuButton.tap() + XCTAssertTrue(app.buttons["library.close"].waitForExistence(timeout: 15)) + app.buttons["library.close"].tap(); waitForStops(1); completeTranscript() + try replaceDraft("Background capture owner. ") + startCapture(); XCUIDevice.shared.press(.home); app.activate() + waitForStops(2); completeTranscript() + let heldNewChatDraft = app.textViews["chat.composer"].value as? String + app.mapleMenuButton.tap() + let recent = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH %@", "library.chat.")).firstMatch + XCTAssertTrue(recent.waitForExistence(timeout: 20)) + let savedChatID = recent.identifier + recent.tap(); try replaceDraft("Navigation capture owner. ") + startCapture(); app.buttons["chat.new"].tap() + waitForStops(3) + XCTAssertEqual(app.textViews["chat.composer"].value as? String, heldNewChatDraft) + app.buttons["lifecycle.transcript"].tap() + XCTAssertEqual(app.textViews["chat.composer"].value as? String, heldNewChatDraft) + app.mapleMenuButton.tap() + XCTAssertTrue(app.buttons[savedChatID].waitForExistence(timeout: 20)); app.buttons[savedChatID].tap() + let originalOwner = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + (self.app.textViews["chat.composer"].value as? String)?.contains("synthetic dictated text") == true + }, object: app) + XCTAssertEqual(XCTWaiter.wait(for: [originalOwner], timeout: 15), .completed) + XCTAssertFalse(app.buttons["chat.stop"].exists, "Finishing a transcript must never send its draft") + app.terminate(); app.launch() + XCTAssertTrue(app.textViews["chat.composer"].waitForExistence(timeout: 30)) + XCTAssertFalse(app.buttons["dictation.stop"].exists) + XCTAssertFalse(app.buttons["dictation.discard"].exists) + XCTAssertEqual(app.staticTexts["lifecycle.capture-counts"].label, "Starts 0, stops 0, cancelled 0") + } +} diff --git a/apps/maple-ios/UITests/RootListeningUITests.swift b/apps/maple-ios/UITests/RootListeningUITests.swift new file mode 100644 index 000000000..b2e994925 --- /dev/null +++ b/apps/maple-ios/UITests/RootListeningUITests.swift @@ -0,0 +1,469 @@ +import Foundation +import XCTest +import UIKit + +/// Normal local AppEnvironment, SDK, durable runtime, speech service and native +/// player. Only microphone/transcription input and the timing of one next SDK +/// Responses request are controlled. No credentials enter XCTest activity logs. +@MainActor final class RootListeningUITests: XCTestCase { + private var app: XCUIApplication! + private var composer: XCUIElement { app.textViews["chat.composer"] } + private var diagnostics: XCUIElement { app.staticTexts["listening.diagnostics"] } + + private func launch() throws { + try XCTSkipUnless(ProcessInfo.processInfo.environment["MAPLE_RUN_LOCAL_UI"] == "1") + continueAfterFailure = false + app = XCUIApplication(); app.launchArguments = ["--maple-local-listening-fixture"]; app.launch() + XCTAssertTrue(app.buttons["chat.new"].waitForExistence(timeout: 30)) + XCTAssertTrue(diagnostics.waitForExistence(timeout: 20)) + tap("chat.new") + _ = try waitState { $0.string("phase") == "closed" && $0.int("completions") == 0 } + } + + private func state() throws -> [String: Any] { + guard let text = diagnostics.value as? String, let data = text.data(using: .utf8), data.count <= 8_192, + let value = try JSONSerialization.jsonObject(with: data) as? [String: Any] + else { throw ListeningProofIssue.diagnosticsUnavailable } + return value + } + @discardableResult private func waitState( + timeout: TimeInterval = 90, _ predicate: @escaping ([String: Any]) -> Bool + ) throws -> [String: Any] { + let condition = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + guard let value = try? self.state() else { return false } + return predicate(value) + }, object: diagnostics) + guard XCTWaiter.wait(for: [condition], timeout: timeout) == .completed else { + let lastState: String + if let value = try? state(), + let data = try? JSONSerialization.data(withJSONObject: value, options: [.sortedKeys]), + data.count <= 8_192, let text = String(data: data, encoding: .utf8) + { + lastState = text + } else { + lastState = "Local listening diagnostics unavailable at timeout" + } + let record = XCTAttachment(string: lastState) + record.name = "Local listening last state at transition timeout" + record.lifetime = .keepAlways; add(record) + XCTFail("Local listening state did not reach the required transition") + throw ListeningProofIssue.transitionTimedOut + } + return try state() + } + private func tap(_ id: String) { + let button = id == "chat.menu" ? app.mapleMenuButton : app.buttons[id].firstMatch + XCTAssertTrue(button.waitForExistence(timeout: 15)); XCTAssertTrue(button.isEnabled) + XCTAssertTrue(button.isHittable); button.tap() + } + private func selectNormalPlaybackSpeed() { + let choices = app.buttons.matching( + NSPredicate(format: "label == %@ AND identifier != %@", "1×", "audio.speed")) + func availableChoices() -> [XCUIElement] { + choices.allElementsBoundByIndex.filter { $0.exists && $0.isEnabled && $0.isHittable } + } + let ready = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in availableChoices().count == 1 }, object: nil) + XCTAssertEqual(XCTWaiter.wait(for: [ready], timeout: 15), .completed) + let availableCount = availableChoices().count + XCTAssertEqual(availableCount, 1, "Playback speed must have one available exact 1× choice") + guard availableCount == 1 else { return } + let choice = choices.firstMatch + XCTAssertTrue(choice.exists, "The exact 1× playback speed choice must exist") + XCTAssertTrue(choice.isEnabled, "The exact 1× playback speed choice must be enabled") + XCTAssertTrue(choice.isHittable, "The exact 1× playback speed choice must be hittable") + choices.firstMatch.tap() + } + private func replace(_ text: String) throws { try replaceSyntheticText(composer, with: text, in: app) } + private func autoplay() { + let control = app.switches["audio.autoplay"] + XCTAssertTrue(control.waitForExistence(timeout: 15)); XCTAssertTrue(control.isHittable) + if control.value as? String != "1" { control.tap() } + XCTAssertEqual(control.value as? String, "1") + } + private func seedReply() throws -> String { + try replace( + "Reply with exactly this synthetic test paragraph: Maple keeps one reply ready while a new draft is composed. The native player can pause, resume, and keep pending replies in order. These sentences contain no personal information. Every test action is explicit, and closing the player clears the temporary reading session. A separate composer holds the next message while the original audio remains available. Opening the menu changes the visible screen without transferring ownership of a reply. Returning to the conversation keeps the same reading position and does not start a missed answer." + ) + tap("chat.send") + let completed = try waitState { $0.int("completions") == 1 } + XCTAssertTrue(completed.bool("completionVisible")); XCTAssertTrue(completed.bool("completionForeground")) + XCTAssertFalse(app.staticTexts["chat.error"].exists) + let responseID = completed.string("completionID") + XCTAssertNotEqual(responseID, "none") + let record = XCTAttachment(string: completed.string("completionChat")) + record.name = "Exact synthetic local listening chat ID for cleanup"; record.lifetime = .keepAlways; add(record) + let speaker = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH %@", "message.speak.")).firstMatch + XCTAssertTrue(speaker.waitForExistence(timeout: 10)); XCTAssertTrue(speaker.isEnabled) + try reveal(speaker) + speaker.tap() + _ = try waitState { $0.string("current") == responseID && $0.string("phase") == "playing" } + // Configuration must not consume the real reply before the case receives a playing seed. + tap("audio.play-pause") + _ = try waitState(timeout: 10) { + $0.string("current") == responseID && $0.bool("manual") && $0.string("phase") == "paused" + } + tap("audio.speed"); selectNormalPlaybackSpeed() + _ = try waitState(timeout: 10) { $0.number("speed") == 1 } + autoplay() + tap("audio.play-pause") + _ = try waitState(timeout: 10) { + $0.string("current") == responseID && !$0.bool("manual") && $0.string("phase") == "playing" + } + return responseID + } + private func reveal(_ action: XCUIElement) throws { + for _ in 0..<10 { + let top = max(app.navigationBars.firstMatch.frame.maxY, diagnostics.frame.maxY) + 12 + let bottom = composer.frame.minY - 24 + guard bottom - top >= 80 else { throw ListeningProofIssue.insufficientViewport } + if action.isHittable, action.frame.minY >= top, action.frame.maxY <= bottom { return } + let origin = app.coordinate(withNormalizedOffset: .zero), x = app.frame.midX + origin.withOffset(CGVector(dx: x, dy: bottom - 12)).press( + forDuration: 0.05, thenDragTo: origin.withOffset(CGVector(dx: x, dy: top + 12))) + } + XCTFail("Speaker did not become visible above the real composer") + throw ListeningProofIssue.insufficientViewport + } + private func dictate() throws { + try replace("Reply in one short sentence about this phrase: ") + tap("dictation.start") + _ = try waitState(timeout: 15) { $0.string("hold") == "capture" } + if app.buttons["audio.play-pause"].exists { XCTAssertFalse(app.buttons["audio.play-pause"].isEnabled) } + tap("dictation.stop") + _ = try waitState(timeout: 15) { $0.string("hold") == "transcription" } + if app.buttons["audio.play-pause"].exists { XCTAssertFalse(app.buttons["audio.play-pause"].isEnabled) } + tap("lifecycle.transcript") + _ = try waitState(timeout: 15) { $0.string("hold") == "review" } + let inserted = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.composer.value as? String + == "Reply in one short sentence about this phrase: synthetic dictated text" + }, object: composer) + XCTAssertEqual(XCTWaiter.wait(for: [inserted], timeout: 15), .completed) + XCTAssertFalse(app.buttons["dictation.discard"].exists) + } + private func submitHeld(expectedAcceptedCount: Int) throws -> [String: Any] { + tap("listening.hold-request") + _ = try waitState(timeout: 10) { $0.bool("armed") } + tap("chat.send") + let held = try waitState(timeout: 30) { + $0.bool("waiting") && $0.int("acceptedCount") == expectedAcceptedCount + } + XCTAssertNotNil(UUID(uuidString: held.string("acceptedID"))) + return held + } + private func closePlayer() throws { + tap("audio.close") + _ = try waitState(timeout: 15) { + $0.string("phase") == "closed" && $0.string("current") == "none" + && $0.strings("pending").isEmpty && !$0.bool("autoplay") + } + XCTAssertFalse(app.buttons["audio.close"].exists) + } + private func snapshot(_ name: String) { + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = name; attachment.lifetime = .keepAlways; add(attachment) + } + + private struct ReloginCredentials: Decodable { let email: String; let password: String } + private func credentials() throws -> ReloginCredentials { + let support = try FileManager.default.url( + for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + let path = support.appendingPathComponent("MapleUITestCredentials.json") + let attributes = try FileManager.default.attributesOfItem(atPath: path.path) + guard attributes[.type] as? FileAttributeType == .typeRegular, + let size = attributes[.size] as? NSNumber, size.intValue <= 4_096 + else { throw ListeningProofIssue.diagnosticsUnavailable } + let value = try JSONDecoder().decode(ReloginCredentials.self, from: Data(contentsOf: path)) + try FileManager.default.removeItem(at: path) + return value + } + private func pasteCredential(_ value: String, into field: XCUIElement) throws { + guard field.waitForExistence(timeout: 15) else { throw ListeningProofIssue.diagnosticsUnavailable } + UIPasteboard.general.setItems( + [["public.utf8-plain-text": value]], + options: [.localOnly: true, .expirationDate: Date().addingTimeInterval(30)]) + field.tap(); field.press(forDuration: 1.2) + let menu = app.menuItems["Paste"].firstMatch, button = app.buttons["Paste"].firstMatch + if menu.waitForExistence(timeout: 3) { + menu.tap() + } else if button.waitForExistence(timeout: 3) { + button.tap() + } else { + throw ListeningProofIssue.diagnosticsUnavailable + } + // Keep the private clipboard item until this real form reports committed input. + // The predicate exposes no credential value in its assertion or diagnostics. + guard field.identifier == "auth.identifier" || field.identifier == "auth.password" else { return } + let committed = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + if field.identifier == "auth.identifier" { + return field.value as? String == value + } + let submit = self.app.buttons["auth.submit"].firstMatch + return submit.exists && submit.isEnabled + }, object: app) + XCTAssertEqual(XCTWaiter.wait(for: [committed], timeout: 10), .completed) + } + private func listButton(_ id: String, maximumSwipes: Int = 16) throws { + let button = id == "chat.menu" ? app.mapleMenuButton : app.buttons[id].firstMatch + for _ in 0.. CGRect { + guard owner.exists else { return .null } + return owner.frame.intersection(app.frame).insetBy(dx: 16, dy: 24) + } + let ready = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + let region = visibleRegion() + guard !region.isNull, !region.isInfinite, region.width >= 100, region.height >= 100 else { + return false + } + return owner.descendants(matching: .any).allElementsBoundByIndex.prefix(512).contains { + $0.exists && $0.isHittable && !$0.frame.intersection(region).isEmpty + } + }, object: owner) + guard XCTWaiter.wait(for: [ready], timeout: 15) == .completed else { + XCTFail("Settings did not expose its visible form") + throw ListeningProofIssue.insufficientViewport + } + let button = owner.buttons["settings.sign-out"].firstMatch + for _ in 0..<16 { + if button.exists && button.isHittable { break } + let region = visibleRegion() + guard !region.isNull, !region.isInfinite, region.width >= 44, region.height >= 100 else { + throw ListeningProofIssue.insufficientViewport + } + let origin = app.coordinate(withNormalizedOffset: .zero) + origin.withOffset( + CGVector(dx: region.midX - app.frame.minX, dy: region.maxY - region.height * 0.15 - app.frame.minY) + ) + .press( + forDuration: 0.05, + thenDragTo: origin.withOffset( + CGVector(dx: region.midX - app.frame.minX, dy: region.minY + region.height * 0.15 - app.frame.minY)) + ) + } + XCTAssertTrue(button.exists); XCTAssertTrue(button.isEnabled); XCTAssertTrue(button.isHittable) + button.tap() + } + private func openExactChat(_ id: String) throws { + tap("chat.menu") + guard let button = try MenuChatUI.find(id, in: app, projectIDs: [], pageLimit: 20) else { + XCTFail("The exact unassigned queue conversation is absent from the menu") + throw ListeningProofIssue.insufficientViewport + } + XCTAssertEqual(button.identifier, "library.chat." + id) + XCTAssertTrue(button.exists); XCTAssertTrue(button.isHittable); XCTAssertTrue(button.isEnabled); button.tap() + XCTAssertTrue(composer.waitForExistence(timeout: 20)) + } + + func testOrdinarySignOutClearsRealQueueAndRejectsLateTranscriptAfterSameAccountRelogin() throws { + try XCTSkipUnless(UIDevice.current.userInterfaceIdiom == .phone, "This scenario uses compact menu navigation") + let credentials = try credentials() + defer { UIPasteboard.general.items = [] } + try launch() + try replace("Synthetic request held before its actual SDK dispatch") + let held = try submitHeld(expectedAcceptedCount: 1) + let oldChat = held.string("assignedChat") + XCTAssertNotEqual(oldChat, "none") + try replace("Actual queued A must be cleared on sign out") + tap("chat.send"); tap("queue.open") + XCTAssertTrue(app.staticTexts["Actual queued A must be cleared on sign out"].waitForExistence(timeout: 10)) + if app.buttons["queue.pause"].exists { tap("queue.pause") } + snapshot("Reference — actual local root queue review — request held before dispatch") + tap("Done") + try replace("Pending transcript belongs to the old account draft. ") + tap("dictation.start"); tap("dictation.stop") + _ = try waitState { $0.string("hold") == "transcription" } + for _ in 0..<5 { + tap("listening.inspect-transcripts") + if try state().int("pendingTranscripts") == 1 { break } + } + _ = try waitState(timeout: 10) { $0.int("pendingTranscripts") == 1 } + let record = XCTAttachment(string: oldChat) + record.name = "Exact synthetic queue cleanup conversation ID"; record.lifetime = .keepAlways; add(record) + tap("chat.menu") + tap("library.actions") + let settings = app.buttons["library.settings"] + XCTAssertTrue(settings.waitForExistence(timeout: 20)); XCTAssertTrue(settings.isHittable) + XCTAssertTrue(settings.isEnabled); settings.tap(); try signOutFromSettings() + XCTAssertTrue(app.textFields["auth.identifier"].waitForExistence(timeout: 30)) + XCTAssertFalse(app.alerts.firstMatch.exists, "Ordinary sign out must not ask to retain queued work") + try pasteCredential(credentials.email, into: app.textFields["auth.identifier"]) + try pasteCredential(credentials.password, into: app.secureTextFields["auth.password"]) + UIPasteboard.general.items = [] + tap("auth.submit") + XCTAssertTrue(composer.waitForExistence(timeout: 45)) + _ = try waitState { $0.int("acceptedCount") == 0 && $0.int("completions") == 0 && !$0.bool("waiting") } + XCTAssertEqual(composer.value as? String, "") + XCTAssertFalse(app.buttons["queue.open"].exists) + XCTAssertFalse(app.buttons["dictation.discard"].exists) + try replace("Replacement account draft stays private and unchanged") + tap("lifecycle.transcript") + _ = try waitState(timeout: 15) { $0.int("finishedTranscripts") == 1 } + XCTAssertEqual(composer.value as? String, "Replacement account draft stays private and unchanged") + try openExactChat(oldChat) + XCTAssertEqual(composer.value as? String, "") + XCTAssertFalse(app.buttons["queue.open"].exists) + XCTAssertFalse(app.buttons["dictation.discard"].exists) + XCTAssertFalse(app.buttons["chat.stop"].exists) + XCTAssertEqual(try state().int("acceptedCount"), 0) + XCTAssertEqual(try state().int("completions"), 0) + tap("chat.new") + // Selection starts in a Task; wait for the retained draft to reach the + // native editor before reading the same full-value invariant. + let restoredDraft = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.composer.exists + && self.composer.value as? String == "Replacement account draft stays private and unchanged" + }, object: composer) + XCTAssertEqual(XCTWaiter.wait(for: [restoredDraft], timeout: 10), .completed) + XCTAssertEqual(composer.value as? String, "Replacement account draft stays private and unchanged") + app.terminate(); app.launch() + XCTAssertTrue(composer.waitForExistence(timeout: 30)) + try openExactChat(oldChat) + XCTAssertEqual(composer.value as? String, "") + XCTAssertFalse(app.buttons["queue.open"].exists) + XCTAssertFalse(app.buttons["dictation.discard"].exists) + XCTAssertFalse(app.buttons["chat.stop"].exists) + snapshot("Ordinary root sign out removes real queued work and rejects the old transcript") + app.terminate() + } + + func testManualPauseSurvivesDictationSendAndPreservesPendingFIFO() throws { + try launch() + let original = try seedReply() + tap("audio.play-pause") + _ = try waitState(timeout: 10) { $0.bool("manual") && $0.string("phase") == "paused" } + try dictate() + let accepted = try submitHeld(expectedAcceptedCount: 2) + XCTAssertEqual(accepted.string("acceptedCurrent"), original) + XCTAssertEqual(accepted.string("acceptedPhase"), "paused") + XCTAssertEqual(accepted.string("acceptedHold"), "none") + XCTAssertEqual(accepted.string("acceptedArm"), accepted.string("acceptedID")) + XCTAssertTrue(accepted.bool("manual")) + tap("listening.release-request") + let second = try waitState { $0.int("completions") == 2 } + let firstPending = second.string("completionID"), firstTurn = second.string("completionMessage") + XCTAssertEqual(firstTurn, accepted.string("acceptedID")) + XCTAssertEqual(second.strings("pending"), [firstPending]) + XCTAssertEqual(second.strings("pendingTurns"), [firstTurn]) + XCTAssertEqual(second.string("current"), original); XCTAssertEqual(second.string("phase"), "paused") + try replace("Reply with exactly: A second pending synthetic reply.") + tap("chat.send") + let third = try waitState { $0.int("completions") == 3 } + let secondPending = third.string("completionID") + XCTAssertEqual(third.strings("pending"), [firstPending, secondPending]) + XCTAssertEqual(third.string("current"), original); XCTAssertTrue(third.bool("manual")) + + tap("chat.menu"); XCTAssertTrue(app.buttons["library.close"].waitForExistence(timeout: 15)) + tap("library.close") + let returned = try state() + XCTAssertEqual(returned.string("phase"), "paused") + XCTAssertEqual(returned.strings("pending"), [firstPending, secondPending]) + tap("chat.menu"); XCTAssertTrue(app.buttons["library.close"].waitForExistence(timeout: 15)) + // Explicit Play can resume the current reply while its chat is hidden. + tap("audio.play-pause") + _ = try waitState(timeout: 15) { $0.string("phase") == "playing" && !$0.bool("manual") } + let duration = try state().number("duration") + XCTAssertGreaterThan(duration, 0); XCTAssertLessThanOrEqual(duration, 120) + for _ in 0..
\(code.htmlCodeEscaped())
" + case .blockQuote(let children): + return "
\n\(children.renderHTML())\n
" + case .table(let columns, let children): + guard let headerHTML = children.first?.renderHeaderHTML(columns: columns) else { + return "
" + } + let bodyHTML = Array(children.dropFirst()).renderHTML(columns: columns) + + if bodyHTML.isEmpty { + return "\n\n\(headerHTML)\n\n
" + } else { + return "\n\n\(headerHTML)\n\n\(bodyHTML)\n
" + } + case .thematicBreak: + return "
" + } + } +} + +extension Array where Element == Formatter.BlockNode { + fileprivate func renderHTML() -> String { + self.map { + $0.renderHTML() + }.joined(separator: "\n") + } +} + +// MARK: - Table rendering + +extension Formatter.TableRow { + fileprivate func renderHeaderHTML(columns: [PresentationIntent.TableColumn]) -> String { + renderHTML("th", columns: columns) + } + + fileprivate func renderDataHTML(columns: [PresentationIntent.TableColumn]) -> String { + renderHTML("td", columns: columns) + } + + private func renderHTML( + _ element: String, + columns: [PresentationIntent.TableColumn] + ) -> String { + let alignments = columns.map(\.alignment) + let cells = zip(alignments, self.cells).map { alignment, inlines in + #"<\#(element) align="\#(alignment)">\#(inlines.renderHTML())"# + } + return "\n\(cells.joined(separator: "\n"))\n" + } +} + +extension Array where Element == Formatter.TableRow { + fileprivate func renderHTML(columns: [PresentationIntent.TableColumn]) -> String { + self.map { + $0.renderDataHTML(columns: columns) + }.joined(separator: "\n") + } +} + +// MARK: - List rendering + +extension Formatter.ListItem { + fileprivate func renderHTML() -> String { + // NB: Tight vs. loose list detection. When Markdown has blank lines between list items, + // the parser creates multiple paragraph blocks. Single paragraph = tight (no

tags), + // multiple paragraphs = loose (wrap content in

tags). + let paragraphCount = blocks.filter { + if case .paragraph = $0 { return true } + return false + }.count + + // Tight list - single paragraph + if paragraphCount == 1, + blocks.count == 1, + case .paragraph(let children) = blocks[0] + { + return "

  • \(children.renderHTML())
  • " + } + + // Tight list with single paragraph + other blocks (nested lists, etc.) + if paragraphCount == 1, + let firstParagraphIndex = blocks.firstIndex(where: { + if case .paragraph = $0 { return true } + return false + }), + case .paragraph(let children) = blocks[firstParagraphIndex] + { + let otherBlocks = blocks.enumerated().filter { $0.offset != firstParagraphIndex }.map { + $0.element + } + let otherHTML = otherBlocks.renderHTML() + return "
  • \(children.renderHTML())\(otherHTML)
  • " + } + + // Loose list - multiple paragraphs mean blank lines existed + return "
  • \(blocks.renderHTML())
  • " + } +} + +extension Array where Element == Formatter.ListItem { + fileprivate func renderHTML() -> String { + self.map { + $0.renderHTML() + }.joined(separator: "\n") + } +} + +// MARK: - String escaping + +extension String { + // For text content. Escapes &, <, >, and non-ASCII characters. + fileprivate func htmlEscaped(preservingLineBreaks: Bool = false) -> String { + var result = "" + result.reserveCapacity(utf8.count) + + for scalar in self.unicodeScalars { + switch scalar.value { + case 0x0A where preservingLineBreaks: + result.append("
    ") + case 0x26: // & + result.append("&") + case 0x3C: // < + result.append("<") + case 0x3E: // > + result.append(">") + case 0x20...0x7E: + result.append(Character(scalar)) + default: + result.append("&#\(scalar.value);") + } + } + + return result + } + + // For code blocks. Only escapes &, <, > to preserve whitespace. + fileprivate func htmlCodeEscaped() -> String { + var result = "" + result.reserveCapacity(utf8.count) + + for scalar in self.unicodeScalars { + switch scalar.value { + case 0x26: // & + result.append("&") + case 0x3C: // < + result.append("<") + case 0x3E: // > + result.append(">") + default: + result.append(Character(scalar)) + } + } + + return result + } + + // For attribute values. Escapes &, <, >, ", and non-ASCII characters. + fileprivate func htmlAttributeEscaped() -> String { + var result = "" + result.reserveCapacity(utf8.count) + + for scalar in self.unicodeScalars { + switch scalar.value { + case 0x26: // & + result.append("&") + case 0x3C: // < + result.append("<") + case 0x3E: // > + result.append(">") + case 0x22: // " + result.append(""") + case 0x20...0x7E: + result.append(Character(scalar)) + default: + result.append("&#\(scalar.value);") + } + } + + return result + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Formatting/Formatter+PlainText.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Formatting/Formatter+PlainText.swift new file mode 100644 index 000000000..08136648e --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Formatting/Formatter+PlainText.swift @@ -0,0 +1,200 @@ +import Foundation + +extension Formatter { + func plainText() -> String { + blockNodes.renderPlainText() + } +} + +// MARK: - Inline rendering + +extension Formatter.InlineNode { + fileprivate func renderPlainText() -> String { + switch self { + case .text(let text): + return text + case .code(let code): + return code + case .strong(let children): + return children.renderPlainText() + case .emphasized(let children): + return children.renderPlainText() + case .strikethrough(let children): + return children.renderPlainText() + case .link(_, let children): + return children.renderPlainText() + case .lineBreak: + return "\n" + case .attachment(let attachment): + return attachment.description + } + } +} + +extension Array where Element == Formatter.InlineNode { + fileprivate func renderPlainText() -> String { + self.map { + $0.renderPlainText() + }.joined() + } +} + +// MARK: - Block rendering + +extension Formatter.BlockNode { + fileprivate func renderPlainText(indentationLevel: Int, tightSpacing: Bool) -> String { + switch self { + case .paragraph(let children): + return children.renderPlainText().indented(indentationLevel) + case .header(_, let children): + return children.renderPlainText().indented(indentationLevel) + case .orderedList(let children): + return children.renderOrderedPlainText(indentationLevel: indentationLevel + 1) + case .unorderedList(let children): + return children.renderUnorderedPlainText(indentationLevel: indentationLevel + 1) + case .codeBlock(_, let code): + return code.indented(indentationLevel) + case .blockQuote(let children): + return children.renderPlainText( + indentationLevel: indentationLevel + 1, tightSpacing: tightSpacing) + case .table(_, let children): + return children.renderPlainText(indentationLevel: indentationLevel) + case .thematicBreak: + return "***" + } + } +} + +extension Array where Element == Formatter.BlockNode { + fileprivate func renderPlainText(indentationLevel: Int = 0, tightSpacing: Bool = false) + -> String + { + self.map { + $0.renderPlainText(indentationLevel: indentationLevel, tightSpacing: tightSpacing) + }.joined(separator: tightSpacing ? "\n" : "\n\n") + } +} + +// MARK: - Table rendering + +extension Formatter.TableRow { + fileprivate func renderPlainText(indentationLevel: Int = 0) -> String { + cells.map { + $0.renderPlainText().csvEscaped() + } + .joined(separator: ",") + .indented(indentationLevel) + } +} + +extension Array where Element == Formatter.TableRow { + fileprivate func renderPlainText(indentationLevel: Int = 0) -> String { + self.map { + $0.renderPlainText(indentationLevel: indentationLevel) + }.joined(separator: "\n") + } +} + +// MARK: - List rendering + +extension Formatter.ListItem { + fileprivate func renderOrderedPlainText(indentationLevel: Int) -> String { + blocks + .renderPlainText(indentationLevel: indentationLevel, tightSpacing: true) + .prefixed(with: "\(ordinal). ", indentationLevel: indentationLevel) + } + + fileprivate func renderUnorderedPlainText(indentationLevel: Int) -> String { + blocks + .renderPlainText(indentationLevel: indentationLevel, tightSpacing: true) + .prefixed(with: "• ", indentationLevel: indentationLevel) + } +} + +extension Array where Element == Formatter.ListItem { + fileprivate func renderOrderedPlainText(indentationLevel: Int) -> String { + self.map { + $0.renderOrderedPlainText(indentationLevel: indentationLevel) + }.joined(separator: "\n") + } + + fileprivate func renderUnorderedPlainText(indentationLevel: Int) -> String { + self.map { + $0.renderUnorderedPlainText(indentationLevel: indentationLevel) + }.joined(separator: "\n") + } +} + +extension String { + fileprivate func indented(_ level: Int) -> String { + guard level > 0 else { return self } + + let indented = + self + .split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) + .map { String(repeating: " ", count: level) + $0 } + .joined(separator: "\n") + return self.hasSuffix("\n") ? indented + "\n" : indented + } + + fileprivate func prefixed(with prefix: String, indentationLevel: Int) -> String { + let indent = String(repeating: " ", count: indentationLevel) + + let firstLineEnd = self.firstIndex(where: \.isNewline) ?? self.endIndex + var firstLine = self[.. 0, firstLine.hasPrefix(indent) { + firstLine = firstLine.dropFirst(indent.count) + } + + return indent + prefix + firstLine + rest + } + + fileprivate func csvEscaped() -> String { + // Quick edge-space check without scanning the whole string + var needsQuoting = + (self.unicodeScalars.first?.value == 0x20) || (self.unicodeScalars.last?.value == 0x20) + + // Scan once over UTF-8 to detect commas, quotes, or newlines + var needsDoubling = false + for b in self.utf8 { + switch b { + case 0x2C: // , + needsQuoting = true + case 0x22: // " + needsQuoting = true + needsDoubling = true + case 0x0A, // \n + 0x0D: // \r + needsQuoting = true + default: + break + } + } + + if !needsQuoting { + return self + } + + // If we need quotes but there are no double quotes inside, just wrap + if !needsDoubling { + return "\"\(self)\"" + } + + // Otherwise, build once with doubled quotes. + var out = String() + out.reserveCapacity(self.utf8.count + 2) // rough lower bound + out.append("\"") + for ch in self { + if ch == "\"" { + out.append("\"\"") + } else { + out.append(ch) + } + } + out.append("\"") + + return out + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Formatting/Formatter.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Formatting/Formatter.swift new file mode 100644 index 000000000..10222c864 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Formatting/Formatter.swift @@ -0,0 +1,381 @@ +import Foundation + +// MARK: - Overview +// +// Formatter converts AttributedString to structured block and inline nodes for export to +// plain text and HTML. The transformation happens in three stages. +// +// First, AttributedString runs are grouped by PresentationIntent, merging consecutive runs +// with identical intents into single segments. +// +// Then segments are recursively grouped by their intent component hierarchy, building +// a tree where container nodes have children and leaf nodes hold attributed substrings. +// +// Finally, the block tree is mapped to typed BlockNode and InlineNode enums that represent +// paragraphs, headers, lists, code blocks, tables, and inline formatting. +// +// The result is a semantic document structure suitable for rendering to various formats. + +final class Formatter { + lazy var blockNodes: [BlockNode] = makeBlockNodes() + + private let attributedString: AttributedString + + convenience init(_ nsAttributedString: NSAttributedString) { + self.init( + (try? AttributedString( + nsAttributedString, + including: \.textual + )) ?? .init() + ) + } + + init(_ attributedString: AttributedString) { + self.attributedString = attributedString + } +} + +// MARK: - Intermediate representation + +extension Formatter { + fileprivate func makeBlockNodes() -> [BlockNode] { + attributedString.blockNodes + } + + enum InlineNode: Hashable { + case text(String) + case code(String) + case strong(children: [InlineNode]) + case emphasized(children: [InlineNode]) + case strikethrough(children: [InlineNode]) + case link(url: URL, children: [InlineNode]) + case lineBreak + case attachment(AnyAttachment) + } + + struct ListItem: Hashable { + let ordinal: Int + let blocks: [BlockNode] + } + + struct TableRow: Hashable { + let cells: [[InlineNode]] + } + + enum BlockNode: Hashable { + case paragraph(children: [InlineNode]) + case header(level: Int, children: [InlineNode]) + case orderedList(children: [ListItem]) + case unorderedList(children: [ListItem]) + case codeBlock(languageHint: String?, code: String) + case blockQuote(children: [BlockNode]) + case table(columns: [PresentationIntent.TableColumn], children: [TableRow]) + case thematicBreak + } +} + +// MARK: - Hierarchical representation + +extension Formatter { + fileprivate struct Block: Equatable { + struct Container: Equatable { + let children: [Block] + } + + struct Leaf: Equatable { + let attributedString: AttributedSubstring + } + + enum Kind: Equatable { + case container(Container) + case leaf(Leaf) + } + + let intentType: PresentationIntent.IntentType + let kind: Kind + + var container: Container? { + guard case .container(let container) = self.kind else { + return nil + } + return container + } + + var leaf: Leaf? { + guard case .leaf(let leaf) = self.kind else { + return nil + } + return leaf + } + } +} + +extension Formatter { + fileprivate struct Segment { + let components: ArraySlice + let intent: PresentationIntent + var range: Range + + init(intent: PresentationIntent, range: Range) { + self.init( + components: intent.components[intent.components.startIndex.., + intent: PresentationIntent, + range: Range + ) { + self.components = components + self.intent = intent + self.range = range + } + + func dropLastComponent() -> Self { + .init(components: self.components.dropLast(), intent: self.intent, range: self.range) + } + } + + fileprivate struct SegmentGrouping { + let component: PresentationIntent.IntentType + var segments: [Segment] + } +} + +extension Sequence where Element == Formatter.Segment { + fileprivate func groupedByLastComponent() -> [Formatter.SegmentGrouping] { + var groups: [Formatter.SegmentGrouping] = [] + + for segment in self { + guard let component = segment.components.last else { + continue + } + + if groups.isEmpty || groups.last?.component != component { + groups.append(.init(component: component, segments: [segment.dropLastComponent()])) + } else { + groups[groups.index(before: groups.endIndex)].segments.append(segment.dropLastComponent()) + } + } + + return groups + } +} + +// MARK: - AttributedString segmentation + +extension AttributedString { + private func segments() -> [Formatter.Segment] { + var segments: [Formatter.Segment] = [] + + for run in self.runs { + guard let presentationIntent = run.presentationIntent else { + continue + } + + if segments.isEmpty || segments.last?.intent != presentationIntent { + segments.append(.init(intent: presentationIntent, range: run.range)) + } else { + let lastIndex = segments.index(before: segments.endIndex) + let currentRange = segments[lastIndex].range + segments[lastIndex].range = currentRange.lowerBound.. Void + ) -> Progress? { + switch typeIdentifier { + case UTType.plainText.identifier: + completionHandler(formatter.plainText().data(using: .utf8), nil) + case UTType.html.identifier: + completionHandler(formatter.html().data(using: .utf8), nil) + default: + completionHandler(nil, nil) + } + return nil + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Helpers/Array.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Helpers/Array.swift new file mode 100644 index 000000000..b33ad8454 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Helpers/Array.swift @@ -0,0 +1,50 @@ +import Foundation + +extension Array where Element: AnyObject { + func removingIdenticalDuplicates() -> Self { + var identifiers: Set = [] + var result: Self = [] + + result.reserveCapacity(underestimatedCount) + + for element in self { + if identifiers.insert(.init(element)).inserted { + result.append(element) + } + } + + return result + } +} + +extension Array where Element == NSAttributedString { + // Joins attributed strings and tracks the character offset where each original string begins + // in the joined result. The offsets map allows converting ranges in the original strings to + // ranges in the joined string. + func joined() -> (joined: NSAttributedString, characterOffsets: [ObjectIdentifier: Int]) { + guard !isEmpty else { + let attributedString = NSAttributedString() + return (attributedString, [ObjectIdentifier(attributedString): 0]) + } + + guard count > 1 else { + return (self[0], [ObjectIdentifier(self[0]): 0]) + } + + let joined = NSMutableAttributedString() + + var characterOffsets: [ObjectIdentifier: Int] = [:] + characterOffsets.reserveCapacity(underestimatedCount) + + var offset = 0 + + for element in self { + joined.append(element) + + characterOffsets[ObjectIdentifier(element)] = offset + offset += element.length + } + + return (joined, characterOffsets) + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Helpers/AttributedString.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Helpers/AttributedString.swift new file mode 100644 index 000000000..911bbc0cd --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Helpers/AttributedString.swift @@ -0,0 +1,166 @@ +import Foundation + +extension AttributedStringProtocol { + var isMathBlock: Bool { + let attachments = self.attachments() + + guard + attachments.count == 1, + let attachment = attachments.first?.base as? MathAttachment, + case .block = attachment.displayStyle + else { + return false + } + + return String(self.characters[...]) + .trimmingCharacters(in: .whitespacesAndNewlines) == "\u{FFFC}" + } + + func attachments() -> Set { + uniqueValues(for: \.textual.attachment) + } + + func containsValues(for keyPaths: Set>) -> Bool { + runs.contains { run in + keyPaths.first { keyPath in + run.attributes[keyPath: keyPath] != nil + } != nil + } + } + + func uniqueValues(for keyPath: KeyPath) -> Set { + var values: Set = [] + for run in runs { + if let value = run.attributes[keyPath: keyPath] { + values.insert(value) + } + } + return values + } + + func slugified() -> String { + String( + String(characters[...]) + .lowercased() + .map { $0.isWhitespace ? "-" : $0 } + .filter { $0.isLetter || $0.isNumber || $0 == "-" } + .split(separator: "-", omittingEmptySubsequences: true) + .joined(separator: "-") + ) + } +} + +// MARK: - Iterable view into blocks +// +// BlockRuns segments an AttributedString into block-level runs based on PresentationIntent +// boundaries. Each BlockRun represents a contiguous range where the block-level intent +// (the intent component immediately before the parent intent in the hierarchy) remains constant. +// +// When the intent changes or becomes nil, a new boundary is recorded. This allows iterating +// over structural blocks (paragraphs, list items, table cells) without reconstructing the +// entire block tree. + +extension AttributedStringProtocol { + func blockRuns(parent: PresentationIntent.IntentType? = nil) -> AttributedString.BlockRuns { + AttributedString.BlockRuns(attributedString: self, parent: parent) + } +} + +extension AttributedString { + struct BlockRuns: RandomAccessCollection { + struct BlockRun: Sendable { + let intent: PresentationIntent.IntentType? + let range: Range + } + + private struct Boundary: Equatable { + let index: AttributedString.Runs.Index + let intent: PresentationIntent.IntentType? + } + + typealias Element = BlockRun + typealias Index = Int + + private let runs: AttributedString.Runs + private let boundaries: [Boundary] + + init( + attributedString: some AttributedStringProtocol, + parent: PresentationIntent.IntentType? + ) { + self.runs = attributedString.runs + + var boundaries: [Boundary] = [] + var lastIntent: PresentationIntent.IntentType? + + for index in runs.indices { + let intent = runs[index].presentationIntent?.intent(before: parent) + + // Record first run or whenever the intent changes (including nil values) + if boundaries.isEmpty || intent != lastIntent { + boundaries.append(.init(index: index, intent: intent)) + lastIntent = intent + } + } + + self.boundaries = boundaries + } + + var startIndex: Index { boundaries.startIndex } + var endIndex: Index { boundaries.endIndex } + + func index(after i: Index) -> Index { + boundaries.index(after: i) + } + + func index(before i: Index) -> Index { + boundaries.index(before: i) + } + + subscript(position: Index) -> BlockRun { + let boundary = boundaries[position] + let nextRunIndex = + (position + 1 < boundaries.count) + ? boundaries[position + 1].index + : runs.endIndex + let lastRunIndex = runs.index(before: nextRunIndex) + let lowerBound = runs[boundary.index].range.lowerBound + let upperBound = runs[lastRunIndex].range.upperBound + + return BlockRun(intent: boundary.intent, range: lowerBound.. PresentationIntent.IntentType? { + guard let intent else { + return components.last + } + + guard + let index = components.firstIndex(of: intent), + index != components.startIndex + else { + return nil + } + + return components[components.index(before: index)] + } +} + +// MARK: - NSAttributedString + +extension NSAttributedString.Key: TextualCompatible {} + +extension TextualNamespace where Base == NSAttributedString.Key { + static var attachment: Base { + .init(AttributeScopes.TextualAttributes.AttachmentAttribute.name) + } + + static var presentationIntent: Base { + .init(AttributeScopes.FoundationAttributes.PresentationIntentAttribute.name) + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Helpers/Box.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Helpers/Box.swift new file mode 100644 index 000000000..7b6108da7 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Helpers/Box.swift @@ -0,0 +1,38 @@ +import Foundation + +final class Box { + var wrappedValue: Value + + init(_ wrappedValue: Value) { + self.wrappedValue = wrappedValue + } +} + +struct WeakBox { + weak var wrapped: Wrapped? + + init(_ wrapped: Wrapped) { + self.wrapped = wrapped + } +} + +final class KeyBox: NSObject { + var wrappedValue: Value + + init(_ wrappedValue: Value) { + self.wrappedValue = wrappedValue + } + + override var hash: Int { + var hasher = Hasher() + hasher.combine(wrappedValue) + return hasher.finalize() + } + + override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? KeyBox else { + return false + } + return wrappedValue == other.wrappedValue + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Helpers/Bundle.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Helpers/Bundle.swift new file mode 100644 index 000000000..3742ea7c6 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Helpers/Bundle.swift @@ -0,0 +1,47 @@ +import Foundation + +private class Token {} + +extension Bundle { + // NB: Alternative to `Bundle.module` that does not crash when the bundle is not found + static let textual: Bundle? = { + let bundleName = "textual_Textual" + + let overrides: [URL] + #if DEBUG + // The 'PACKAGE_RESOURCE_BUNDLE_PATH' name is preferred since the expected value is a path. The + // check for 'PACKAGE_RESOURCE_BUNDLE_URL' will be removed when all clients have switched over. + // This removal is tracked by rdar://107766372. + if let override = ProcessInfo.processInfo.environment["PACKAGE_RESOURCE_BUNDLE_PATH"] + ?? ProcessInfo.processInfo.environment["PACKAGE_RESOURCE_BUNDLE_URL"] + { + overrides = [URL(fileURLWithPath: override)] + } else { + overrides = [] + } + #else + overrides = [] + #endif + + let candidates = + overrides + [ + // Bundle should be present here when the package is linked into an App. + Bundle.main.resourceURL, + + // Bundle should be present here when the package is linked into a framework. + Bundle(for: Token.self).resourceURL, + + // For command-line tools. + Bundle.main.bundleURL, + ] + + for candidate in candidates { + let bundlePath = candidate?.appendingPathComponent(bundleName + ".bundle") + if let bundle = bundlePath.flatMap(Bundle.init(url:)) { + return bundle + } + } + + return nil + }() +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Helpers/CGImage+PNGData.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Helpers/CGImage+PNGData.swift new file mode 100644 index 000000000..ff866bbfa --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Helpers/CGImage+PNGData.swift @@ -0,0 +1,23 @@ +import Foundation +import ImageIO +import UniformTypeIdentifiers + +extension CGImage { + func pngData() -> Data? { + let data = NSMutableData() + guard + let destination = CGImageDestinationCreateWithData( + data, + UTType.png.identifier as CFString, + 1, nil + ) + else { + return nil + } + + CGImageDestinationAddImage(destination, self, nil) + CGImageDestinationFinalize(destination) + + return data as Data + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Helpers/Logger.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Helpers/Logger.swift new file mode 100644 index 000000000..86efa71f5 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Helpers/Logger.swift @@ -0,0 +1,52 @@ +import Foundation +import os + +extension Logger { + enum Textual { + struct Category: RawRepresentable, Hashable { + let rawValue: String + + init(rawValue: String) { + self.rawValue = rawValue + } + } + } + + private enum Constants { + static let subsystem = "com.github.gonzalezreal.Textual" + static let defaultsKey = "\(subsystem).Debug" + } + + init(category: Textual.Category) { + if UserDefaults.standard.bool(forKey: Constants.defaultsKey) { + self.init(subsystem: Constants.subsystem, category: category.rawValue) + } else { + self.init(.disabled) + } + } +} + +extension CGPoint { + var logDescription: String { + "(\(String(format: "%.1f", x)), \(String(format: "%.1f", y)))" + } +} + +extension CGRect { + var logDescription: String { + "(" + "\(String(format: "%.1f", origin.x)), " + "\(String(format: "%.1f", origin.y)), " + + "\(String(format: "%.1f", size.width)), " + "\(String(format: "%.1f", size.height))" + ")" + } +} + +extension NSAttributedString { + var logDescription: String { + self.string.replacingOccurrences(of: "\n", with: "\\n") + } +} + +extension Optional where Wrapped: CustomStringConvertible { + var logDescription: String { + self?.description ?? "nil" + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Helpers/PlatformImage.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Helpers/PlatformImage.swift new file mode 100644 index 000000000..1aa8f53e0 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Helpers/PlatformImage.swift @@ -0,0 +1,68 @@ +import SwiftUI + +#if canImport(UIKit) + typealias PlatformImage = UIImage +#elseif canImport(AppKit) + typealias PlatformImage = NSImage +#endif + +extension PlatformImage { + static func resolve( + _ name: String, + bundle: Bundle?, + environment: ColorEnvironmentValues + ) -> PlatformImage? { + #if canImport(AppKit) && !targetEnvironment(macCatalyst) + guard let appearance = NSAppearance(environment: environment) else { + return nil + } + var image: NSImage? + appearance.performAsCurrentDrawingAppearance { + if let bundle, bundle != .main { + image = bundle.image(forResource: name) + } else { + image = NSImage(named: name) + } + } + return image + #elseif canImport(UIKit) && !os(watchOS) + PlatformImage( + named: name, + in: bundle, + compatibleWith: UITraitCollection(userInterfaceStyle: .init(environment.colorScheme)) + .modifyingTraits { $0.accessibilityContrast = .init(environment.colorSchemeContrast) } + ) + #else + PlatformImage(named: name, in: bundle, with: nil) + #endif + } +} + +extension SwiftUI.Image { + init(_ platformImage: PlatformImage) { + #if canImport(UIKit) + self.init(uiImage: platformImage) + #elseif canImport(AppKit) + self.init(nsImage: platformImage) + #endif + } +} + +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + extension NSAppearance { + convenience init?(environment: ColorEnvironmentValues) { + switch (environment.colorScheme, environment.colorSchemeContrast) { + case (.light, .standard): + self.init(named: .aqua) + case (.dark, .standard): + self.init(named: .darkAqua) + case (.light, .increased): + self.init(named: .accessibilityHighContrastAqua) + case (.dark, .increased): + self.init(named: .accessibilityHighContrastDarkAqua) + case (_, _): + self.init(named: .aqua) + } + } + } +#endif diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Helpers/Range.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Helpers/Range.swift new file mode 100644 index 000000000..1f2aed6ff --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Helpers/Range.swift @@ -0,0 +1,7 @@ +import Foundation + +extension Range where Bound == Int { + func offset(by amount: Int) -> Range { + (lowerBound + amount)..<(upperBound + amount) + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Helpers/Tuple.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Helpers/Tuple.swift new file mode 100644 index 000000000..9776397fe --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Helpers/Tuple.swift @@ -0,0 +1,16 @@ +import Foundation + +struct Tuple: Equatable { + var values: (repeat each V) + + init(_ values: repeat each V) { + self.values = (repeat each values) + } + + static func == (lhs: Self, rhs: Self) -> Bool { + for (left, right) in repeat (each lhs.values, each rhs.values) { + guard left == right else { return false } + } + return true + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Helpers/VerticalAlignment+FirstTextCenter.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Helpers/VerticalAlignment+FirstTextCenter.swift new file mode 100644 index 000000000..54cc14c1f --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Helpers/VerticalAlignment+FirstTextCenter.swift @@ -0,0 +1,16 @@ +import SwiftUI + +extension VerticalAlignment { + private enum FirstTextCenterAlignment: AlignmentID { + static func defaultValue(in context: ViewDimensions) -> CGFloat { + // Calculate the vertical center of the first line of text. The first line height is + // the total height minus the distance between first and last baselines (which gives + // the height of all lines except the first), then divide by 2 to get the center point. + let firstLineHeight = + context.height - (context[.lastTextBaseline] - context[.firstTextBaseline]) + return firstLineHeight / 2 + } + } + + static let firstTextCenter = Self(FirstTextCenterAlignment.self) +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Highlighter/CodeTokenizer.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Highlighter/CodeTokenizer.swift new file mode 100644 index 000000000..1529d202b --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Highlighter/CodeTokenizer.swift @@ -0,0 +1,90 @@ +import Foundation +import os + +#if canImport(JavaScriptCore) + import JavaScriptCore +#endif + +// MARK: - Overview +// +// CodeTokenizer wraps Prism.js via JavaScriptCore for syntax highlighting. The actor +// ensures thread-safe access to the JavaScript context. +// +// The tokenizer gracefully degrades when JavaScriptCore is unavailable, when the +// Prism bundle is missing, or when tokenization fails. In all cases, it returns +// a single plain token containing the entire code string. + +struct CodeToken: Hashable, Sendable { + let content: String + let type: StructuredText.HighlighterTheme.TokenType +} + +#if canImport(JavaScriptCore) + actor CodeTokenizer { + private let context: JSContext + private let logger = Logger(category: .codeTokenizer) + + static let shared = CodeTokenizer() + + init?() { + guard let context = JSContext() else { + logger.error("JavascriptCore is not available.") + return nil + } + + guard + let bundleURL = Bundle.textual?.url( + forResource: "prism-bundle", + withExtension: "js" + ), + let script = try? String(contentsOf: bundleURL, encoding: .utf8) + else { + logger.error("Prism JavaScript bundle is missing.") + return nil + } + + context.evaluateScript(script) + self.context = context + } + + func tokenize(code: String, language: String) -> [CodeToken] { + guard + let tokenizeCode = context.objectForKeyedSubscript("tokenizeCode"), + let result = tokenizeCode.call(withArguments: [code, language]), + let array = result.toArray() as? [[String: String]] + else { + logger.error("Tokenization failed.") + return [CodeToken(content: code, type: .plain)] + } + + return array.compactMap { token in + guard + let content = token["content"], + let type = token["type"] + else { + return nil + } + return CodeToken(content: content, type: .init(rawValue: type)) + } + } + } +#else + actor CodeTokenizer { + private let logger = Logger(category: .codeTokenizer) + + static let shared = CodeTokenizer() + + init?() { + logger.error("JavascriptCore is not available in this platform.") + return nil + } + + func tokenize(code: String, language: String) -> [CodeToken] { + [CodeToken(content: code, type: .plain)] + } + } +#endif + +extension Logger.Textual.Category { + fileprivate static let codeTokenizer = Self(rawValue: "codeTokenizer") +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Highlighter/HighlightedTextFragment.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Highlighter/HighlightedTextFragment.swift new file mode 100644 index 000000000..7d44068d5 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Highlighter/HighlightedTextFragment.swift @@ -0,0 +1,93 @@ +import SwiftUI + +// MARK: - Overview +// +// HighlightedTextFragment displays syntax-highlighted code using a two-phase approach. +// Tokenization runs asynchronously and is keyed by content, while highlighting runs +// synchronously on token or environment changes (theme, color scheme, dynamic type). +// +// The presentationIntent is preserved after highlighting so pasteboard formatters can +// reconstruct the block structure when copying code. + +struct HighlightedTextFragment: View { + @Environment(\.textEnvironment) private var textEnvironment + + @State private var model = Model() + + private let content: AttributedSubstring + private let languageHint: String? + private let theme: StructuredText.HighlighterTheme + + init( + _ content: AttributedSubstring, + languageHint: String?, + theme: StructuredText.HighlighterTheme + ) { + self.content = content + self.languageHint = languageHint + self.theme = theme + } + + var body: some View { + TextFragment(model.highlightedCode ?? AttributedString(content)) + .foregroundStyle(theme.foregroundColor) + .task(id: content) { + await model.tokenize( + content: content, + languageHint: languageHint + ) + } + .onChange(of: Tuple(model.tokens, textEnvironment)) { _, newValue in + model.highlight( + tokens: newValue.values.0, + presentationIntent: content.presentationIntent, + using: theme, + environment: newValue.values.1 + ) + } + } +} + +extension HighlightedTextFragment { + @MainActor @Observable final class Model { + var tokens: [CodeToken] = [] + var highlightedCode: AttributedString? + + func tokenize(content: AttributedSubstring, languageHint: String?) async { + let code = String(content.characters[...]) + tokens = [CodeToken(content: code, type: .plain)] + + if let tokenizer = CodeTokenizer.shared, let languageHint { + tokens = await tokenizer.tokenize(code: code, language: languageHint) + } + } + + func highlight( + tokens: [CodeToken], + presentationIntent: PresentationIntent?, + using theme: StructuredText.HighlighterTheme, + environment: TextEnvironmentValues + ) { + var attributes = AttributeContainer() + // Re-apply the presentation intent for pasteboard formatters + attributes.presentationIntent = presentationIntent + ForegroundColorProperty(theme.foregroundColor) + .apply(in: &attributes, environment: environment) + var highlightedCode = AttributedString() + + for token in tokens { + var content = AttributedString(token.content) + var tokenAttributes = attributes + + if let tokenProperties = theme.tokenProperties[token.type] { + tokenProperties.apply(in: &tokenAttributes, environment: environment) + } + + content.mergeAttributes(tokenAttributes) + highlightedCode.append(content) + } + + self.highlightedCode = highlightedCode + } + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Highlighter/Prism/prism-bundle.js b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Highlighter/Prism/prism-bundle.js new file mode 100644 index 000000000..eb4a08727 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Highlighter/Prism/prism-bundle.js @@ -0,0 +1,104 @@ +// Prism.js v1.29.0 - Bundled on Thu Apr 16 19:34:24 MDT 2026 +// Auto-generated by Scripts/bundle-prism.sh - DO NOT EDIT + +var _self="undefined"!=typeof window?window:"undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?self:{},Prism=function(l){var n=/(?:^|\s)lang(?:uage)?-([\w-]+)(?=\s|$)/i,t=0,e={},j={manual:l.Prism&&l.Prism.manual,disableWorkerMessageHandler:l.Prism&&l.Prism.disableWorkerMessageHandler,util:{encode:function e(t){return t instanceof C?new C(t.type,e(t.content),t.alias):Array.isArray(t)?t.map(e):t.replace(/&/g,"&").replace(/=i.reach);y+=b.value.length,b=b.next){var v=b.value;if(n.length>t.length)return;if(!(v instanceof C)){var F,x=1;if(m){if(!(F=L(f,y,t,p))||F.index>=t.length)break;var k=F.index,w=F.index+F[0].length,A=y;for(A+=b.value.length;A<=k;)b=b.next,A+=b.value.length;if(A-=b.value.length,y=A,b.value instanceof C)continue;for(var P=b;P!==n.tail&&(Ai.reach&&(i.reach=v),b.prev),S=(S&&(_=z(n,_,S),y+=S.length),O(n,_,x),new C(o,d?j.tokenize($,d):$,h,$));b=z(n,_,S),E&&z(n,b,E),1i.reach&&(i.reach=$.reach))}}}}}(e,r,t,r.head,0),r),i=[],o=s.head.next;o!==s.tail;)i.push(o.value),o=o.next;return i},hooks:{all:{},add:function(e,t){var n=j.hooks.all;n[e]=n[e]||[],n[e].push(t)},run:function(e,t){var n=j.hooks.all[e];if(n&&n.length)for(var a,r=0;a=n[r++];)a(t)}},Token:C};function C(e,t,n,a){this.type=e,this.content=t,this.alias=n,this.length=0|(a||"").length}function L(e,t,n,a){e.lastIndex=t;t=e.exec(n);return t&&a&&t[1]&&(e=t[1].length,t.index+=e,t[0]=t[0].slice(e)),t}function u(){var e={value:null,prev:null,next:null},t={value:null,prev:e,next:null};e.next=t,this.head=e,this.tail=t,this.length=0}function z(e,t,n){var a=t.next,n={value:n,prev:t,next:a};return t.next=n,a.prev=n,e.length++,n}function O(e,t,n){for(var a=t.next,r=0;r"+s.content+""},!l.document)return l.addEventListener&&(j.disableWorkerMessageHandler||l.addEventListener("message",function(e){var e=JSON.parse(e.data),t=e.language,n=e.code,e=e.immediateClose;l.postMessage(j.highlight(n,j.languages[t],t)),e&&l.close()},!1)),j;var a,e=j.util.currentScript();function r(){j.manual||j.highlightAll()}return e&&(j.filename=e.src,e.hasAttribute("data-manual")&&(j.manual=!0)),j.manual||("loading"===(a=document.readyState)||"interactive"===a&&e&&e.defer?document.addEventListener("DOMContentLoaded",r):window.requestAnimationFrame?window.requestAnimationFrame(r):window.setTimeout(r,16)),j}(_self);"undefined"!=typeof module&&module.exports&&(module.exports=Prism),"undefined"!=typeof global&&(global.Prism=Prism),Prism.languages.markup={comment:{pattern://,greedy:!0},prolog:{pattern:/<\?[\s\S]+?\?>/,greedy:!0},doctype:{pattern:/"'[\]]|"[^"]*"|'[^']*')+(?:\[(?:[^<"'\]]|"[^"]*"|'[^']*'|<(?!!--)|)*\]\s*)?>/i,greedy:!0,inside:{"internal-subset":{pattern:/(^[^\[]*\[)[\s\S]+(?=\]>$)/,lookbehind:!0,greedy:!0,inside:null},string:{pattern:/"[^"]*"|'[^']*'/,greedy:!0},punctuation:/^$|[[\]]/,"doctype-tag":/^DOCTYPE/i,name:/[^\s<>'"]+/}},cdata:{pattern://i,greedy:!0},tag:{pattern:/<\/?(?!\d)[^\s>\/=$<%]+(?:\s(?:\s*[^\s>\/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+(?=[\s>]))|(?=[\s/>])))+)?\s*\/?>/,greedy:!0,inside:{tag:{pattern:/^<\/?[^\s>\/]+/,inside:{punctuation:/^<\/?/,namespace:/^[^\s>\/:]+:/}},"special-attr":[],"attr-value":{pattern:/=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+)/,inside:{punctuation:[{pattern:/^=/,alias:"attr-equals"},{pattern:/^(\s*)["']|["']$/,lookbehind:!0}]}},punctuation:/\/?>/,"attr-name":{pattern:/[^\s>\/]+/,inside:{namespace:/^[^\s>\/:]+:/}}}},entity:[{pattern:/&[\da-z]{1,8};/i,alias:"named-entity"},/&#x?[\da-f]{1,8};/i]},Prism.languages.markup.tag.inside["attr-value"].inside.entity=Prism.languages.markup.entity,Prism.languages.markup.doctype.inside["internal-subset"].inside=Prism.languages.markup,Prism.hooks.add("wrap",function(e){"entity"===e.type&&(e.attributes.title=e.content.replace(/&/,"&"))}),Object.defineProperty(Prism.languages.markup.tag,"addInlined",{value:function(e,t){var n={},n=(n["language-"+t]={pattern:/(^$)/i,lookbehind:!0,inside:Prism.languages[t]},n.cdata=/^$/i,{"included-cdata":{pattern://i,inside:n}}),t=(n["language-"+t]={pattern:/[\s\S]+/,inside:Prism.languages[t]},{});t[e]={pattern:RegExp(/(<__[^>]*>)(?:))*\]\]>|(?!)/.source.replace(/__/g,function(){return e}),"i"),lookbehind:!0,greedy:!0,inside:n},Prism.languages.insertBefore("markup","cdata",t)}}),Object.defineProperty(Prism.languages.markup.tag,"addAttribute",{value:function(e,t){Prism.languages.markup.tag.inside["special-attr"].push({pattern:RegExp(/(^|["'\s])/.source+"(?:"+e+")"+/\s*=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+(?=[\s>]))/.source,"i"),lookbehind:!0,inside:{"attr-name":/^[^\s=]+/,"attr-value":{pattern:/=[\s\S]+/,inside:{value:{pattern:/(^=\s*(["']|(?!["'])))\S[\s\S]*(?=\2$)/,lookbehind:!0,alias:[t,"language-"+t],inside:Prism.languages[t]},punctuation:[{pattern:/^=/,alias:"attr-equals"},/"|'/]}}}})}}),Prism.languages.html=Prism.languages.markup,Prism.languages.mathml=Prism.languages.markup,Prism.languages.svg=Prism.languages.markup,Prism.languages.xml=Prism.languages.extend("markup",{}),Prism.languages.ssml=Prism.languages.xml,Prism.languages.atom=Prism.languages.xml,Prism.languages.rss=Prism.languages.xml,function(e){var t=/(?:"(?:\\(?:\r\n|[\s\S])|[^"\\\r\n])*"|'(?:\\(?:\r\n|[\s\S])|[^'\\\r\n])*')/,t=(e.languages.css={comment:/\/\*[\s\S]*?\*\//,atrule:{pattern:RegExp("@[\\w-](?:"+/[^;{\s"']|\s+(?!\s)/.source+"|"+t.source+")*?"+/(?:;|(?=\s*\{))/.source),inside:{rule:/^@[\w-]+/,"selector-function-argument":{pattern:/(\bselector\s*\(\s*(?![\s)]))(?:[^()\s]|\s+(?![\s)])|\((?:[^()]|\([^()]*\))*\))+(?=\s*\))/,lookbehind:!0,alias:"selector"},keyword:{pattern:/(^|[^\w-])(?:and|not|only|or)(?![\w-])/,lookbehind:!0}}},url:{pattern:RegExp("\\burl\\((?:"+t.source+"|"+/(?:[^\\\r\n()"']|\\[\s\S])*/.source+")\\)","i"),greedy:!0,inside:{function:/^url/i,punctuation:/^\(|\)$/,string:{pattern:RegExp("^"+t.source+"$"),alias:"url"}}},selector:{pattern:RegExp("(^|[{}\\s])[^{}\\s](?:[^{};\"'\\s]|\\s+(?![\\s{])|"+t.source+")*(?=\\s*\\{)"),lookbehind:!0},string:{pattern:t,greedy:!0},property:{pattern:/(^|[^-\w\xA0-\uFFFF])(?!\s)[-_a-z\xA0-\uFFFF](?:(?!\s)[-\w\xA0-\uFFFF])*(?=\s*:)/i,lookbehind:!0},important:/!important\b/i,function:{pattern:/(^|[^-a-z0-9])[-a-z0-9]+(?=\()/i,lookbehind:!0},punctuation:/[(){};:,]/},e.languages.css.atrule.inside.rest=e.languages.css,e.languages.markup);t&&(t.tag.addInlined("style","css"),t.tag.addAttribute("style","css"))}(Prism),Prism.languages.clike={comment:[{pattern:/(^|[^\\])\/\*[\s\S]*?(?:\*\/|$)/,lookbehind:!0,greedy:!0},{pattern:/(^|[^\\:])\/\/.*/,lookbehind:!0,greedy:!0}],string:{pattern:/(["'])(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/,greedy:!0},"class-name":{pattern:/(\b(?:class|extends|implements|instanceof|interface|new|trait)\s+|\bcatch\s+\()[\w.\\]+/i,lookbehind:!0,inside:{punctuation:/[.\\]/}},keyword:/\b(?:break|catch|continue|do|else|finally|for|function|if|in|instanceof|new|null|return|throw|try|while)\b/,boolean:/\b(?:false|true)\b/,function:/\b\w+(?=\()/,number:/\b0x[\da-f]+\b|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:e[+-]?\d+)?/i,operator:/[<>]=?|[!=]=?=?|--?|\+\+?|&&?|\|\|?|[?*/~^%]/,punctuation:/[{}[\];(),.:]/},Prism.languages.javascript=Prism.languages.extend("clike",{"class-name":[Prism.languages.clike["class-name"],{pattern:/(^|[^$\w\xA0-\uFFFF])(?!\s)[_$A-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\.(?:constructor|prototype))/,lookbehind:!0}],keyword:[{pattern:/((?:^|\})\s*)catch\b/,lookbehind:!0},{pattern:/(^|[^.]|\.\.\.\s*)\b(?:as|assert(?=\s*\{)|async(?=\s*(?:function\b|\(|[$\w\xA0-\uFFFF]|$))|await|break|case|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally(?=\s*(?:\{|$))|for|from(?=\s*(?:['"]|$))|function|(?:get|set)(?=\s*(?:[#\[$\w\xA0-\uFFFF]|$))|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)\b/,lookbehind:!0}],function:/#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*(?:\.\s*(?:apply|bind|call)\s*)?\()/,number:{pattern:RegExp(/(^|[^\w$])/.source+"(?:"+/NaN|Infinity/.source+"|"+/0[bB][01]+(?:_[01]+)*n?/.source+"|"+/0[oO][0-7]+(?:_[0-7]+)*n?/.source+"|"+/0[xX][\dA-Fa-f]+(?:_[\dA-Fa-f]+)*n?/.source+"|"+/\d+(?:_\d+)*n/.source+"|"+/(?:\d+(?:_\d+)*(?:\.(?:\d+(?:_\d+)*)?)?|\.\d+(?:_\d+)*)(?:[Ee][+-]?\d+(?:_\d+)*)?/.source+")"+/(?![\w$])/.source),lookbehind:!0},operator:/--|\+\+|\*\*=?|=>|&&=?|\|\|=?|[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|\.{3}|\?\?=?|\?\.?|[~:]/}),Prism.languages.javascript["class-name"][0].pattern=/(\b(?:class|extends|implements|instanceof|interface|new)\s+)[\w.\\]+/,Prism.languages.insertBefore("javascript","keyword",{regex:{pattern:RegExp(/((?:^|[^$\w\xA0-\uFFFF."'\])\s]|\b(?:return|yield))\s*)/.source+/\//.source+"(?:"+/(?:\[(?:[^\]\\\r\n]|\\.)*\]|\\.|[^/\\\[\r\n])+\/[dgimyus]{0,7}/.source+"|"+/(?:\[(?:[^[\]\\\r\n]|\\.|\[(?:[^[\]\\\r\n]|\\.|\[(?:[^[\]\\\r\n]|\\.)*\])*\])*\]|\\.|[^/\\\[\r\n])+\/[dgimyus]{0,7}v[dgimyus]{0,7}/.source+")"+/(?=(?:\s|\/\*(?:[^*]|\*(?!\/))*\*\/)*(?:$|[\r\n,.;:})\]]|\/\/))/.source),lookbehind:!0,greedy:!0,inside:{"regex-source":{pattern:/^(\/)[\s\S]+(?=\/[a-z]*$)/,lookbehind:!0,alias:"language-regex",inside:Prism.languages.regex},"regex-delimiter":/^\/|\/$/,"regex-flags":/^[a-z]+$/}},"function-variable":{pattern:/#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*[=:]\s*(?:async\s*)?(?:\bfunction\b|(?:\((?:[^()]|\([^()]*\))*\)|(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*)\s*=>))/,alias:"function"},parameter:[{pattern:/(function(?:\s+(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*)?\s*\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\))/,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/(^|[^$\w\xA0-\uFFFF])(?!\s)[_$a-z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*=>)/i,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/(\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\)\s*=>)/,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/((?:\b|\s|^)(?!(?:as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)(?![$\w\xA0-\uFFFF]))(?:(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*\s*)\(\s*|\]\s*\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\)\s*\{)/,lookbehind:!0,inside:Prism.languages.javascript}],constant:/\b[A-Z](?:[A-Z_]|\dx?)*\b/}),Prism.languages.insertBefore("javascript","string",{hashbang:{pattern:/^#!.*/,greedy:!0,alias:"comment"},"template-string":{pattern:/`(?:\\[\s\S]|\$\{(?:[^{}]|\{(?:[^{}]|\{[^}]*\})*\})+\}|(?!\$\{)[^\\`])*`/,greedy:!0,inside:{"template-punctuation":{pattern:/^`|`$/,alias:"string"},interpolation:{pattern:/((?:^|[^\\])(?:\\{2})*)\$\{(?:[^{}]|\{(?:[^{}]|\{[^}]*\})*\})+\}/,lookbehind:!0,inside:{"interpolation-punctuation":{pattern:/^\$\{|\}$/,alias:"punctuation"},rest:Prism.languages.javascript}},string:/[\s\S]+/}},"string-property":{pattern:/((?:^|[,{])[ \t]*)(["'])(?:\\(?:\r\n|[\s\S])|(?!\2)[^\\\r\n])*\2(?=\s*:)/m,lookbehind:!0,greedy:!0,alias:"property"}}),Prism.languages.insertBefore("javascript","operator",{"literal-property":{pattern:/((?:^|[,{])[ \t]*)(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*:)/m,lookbehind:!0,alias:"property"}}),Prism.languages.markup&&(Prism.languages.markup.tag.addInlined("script","javascript"),Prism.languages.markup.tag.addAttribute(/on(?:abort|blur|change|click|composition(?:end|start|update)|dblclick|error|focus(?:in|out)?|key(?:down|up)|load|mouse(?:down|enter|leave|move|out|over|up)|reset|resize|scroll|select|slotchange|submit|unload|wheel)/.source,"javascript")),Prism.languages.js=Prism.languages.javascript,function(){var l,u,g,c,e;void 0!==Prism&&"undefined"!=typeof document&&(Element.prototype.matches||(Element.prototype.matches=Element.prototype.msMatchesSelector||Element.prototype.webkitMatchesSelector),l={js:"javascript",py:"python",rb:"ruby",ps1:"powershell",psm1:"powershell",sh:"bash",bat:"batch",h:"c",tex:"latex"},c="pre[data-src]:not(["+(u="data-src-status")+'="loaded"]):not(['+u+'="'+(g="loading")+'"])',Prism.hooks.add("before-highlightall",function(e){e.selector+=", "+c}),Prism.hooks.add("before-sanity-check",function(e){var r,t,n,a,s,i,o=e.element;o.matches(c)&&(e.code="",o.setAttribute(u,g),(r=o.appendChild(document.createElement("CODE"))).textContent="Loading…",t=o.getAttribute("data-src"),"none"===(e=e.language)&&(n=(/\.(\w+)$/.exec(t)||[,"none"])[1],e=l[n]||n),Prism.util.setLanguage(r,e),Prism.util.setLanguage(o,e),(n=Prism.plugins.autoloader)&&n.loadLanguages(e),n=t,a=function(e){o.setAttribute(u,"loaded");var t,n,a=function(e){var t,n;if(e=/^\s*(\d+)\s*(?:(,)\s*(?:(\d+)\s*)?)?$/.exec(e||""))return t=Number(e[1]),n=e[2],e=e[3],n?e?[t,Number(e)]:[t,void 0]:[t,t]}(o.getAttribute("data-range"));a&&(t=e.split(/\r\n?|\n/g),n=a[0],a=null==a[1]?t.length:a[1],n<0&&(n+=t.length),n=Math.max(0,Math.min(n-1,t.length)),a<0&&(a+=t.length),a=Math.max(0,Math.min(a,t.length)),e=t.slice(n,a).join("\n"),o.hasAttribute("data-start")||o.setAttribute("data-start",String(n+1))),r.textContent=e,Prism.highlightElement(r)},s=function(e){o.setAttribute(u,"failed"),r.textContent=e},(i=new XMLHttpRequest).open("GET",n,!0),i.onreadystatechange=function(){4==i.readyState&&(i.status<400&&i.responseText?a(i.responseText):400<=i.status?s("✖ Error "+i.status+" while fetching file: "+i.statusText):s("✖ Error: File does not exist or is empty"))},i.send(null))}),e=!(Prism.plugins.fileHighlight={highlight:function(e){for(var t,n=(e||document).querySelectorAll(c),a=0;t=n[a++];)Prism.highlightElement(t)}}),Prism.fileHighlight=function(){e||(console.warn("Prism.fileHighlight is deprecated. Use `Prism.plugins.fileHighlight.highlight` instead."),e=!0),Prism.plugins.fileHighlight.highlight.apply(this,arguments)})}(); +Prism.languages.markup={comment:{pattern://,greedy:!0},prolog:{pattern:/<\?[\s\S]+?\?>/,greedy:!0},doctype:{pattern:/"'[\]]|"[^"]*"|'[^']*')+(?:\[(?:[^<"'\]]|"[^"]*"|'[^']*'|<(?!!--)|)*\]\s*)?>/i,greedy:!0,inside:{"internal-subset":{pattern:/(^[^\[]*\[)[\s\S]+(?=\]>$)/,lookbehind:!0,greedy:!0,inside:null},string:{pattern:/"[^"]*"|'[^']*'/,greedy:!0},punctuation:/^$|[[\]]/,"doctype-tag":/^DOCTYPE/i,name:/[^\s<>'"]+/}},cdata:{pattern://i,greedy:!0},tag:{pattern:/<\/?(?!\d)[^\s>\/=$<%]+(?:\s(?:\s*[^\s>\/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+(?=[\s>]))|(?=[\s/>])))+)?\s*\/?>/,greedy:!0,inside:{tag:{pattern:/^<\/?[^\s>\/]+/,inside:{punctuation:/^<\/?/,namespace:/^[^\s>\/:]+:/}},"special-attr":[],"attr-value":{pattern:/=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+)/,inside:{punctuation:[{pattern:/^=/,alias:"attr-equals"},{pattern:/^(\s*)["']|["']$/,lookbehind:!0}]}},punctuation:/\/?>/,"attr-name":{pattern:/[^\s>\/]+/,inside:{namespace:/^[^\s>\/:]+:/}}}},entity:[{pattern:/&[\da-z]{1,8};/i,alias:"named-entity"},/&#x?[\da-f]{1,8};/i]},Prism.languages.markup.tag.inside["attr-value"].inside.entity=Prism.languages.markup.entity,Prism.languages.markup.doctype.inside["internal-subset"].inside=Prism.languages.markup,Prism.hooks.add("wrap",(function(a){"entity"===a.type&&(a.attributes.title=a.content.replace(/&/,"&"))})),Object.defineProperty(Prism.languages.markup.tag,"addInlined",{value:function(a,e){var s={};s["language-"+e]={pattern:/(^$)/i,lookbehind:!0,inside:Prism.languages[e]},s.cdata=/^$/i;var t={"included-cdata":{pattern://i,inside:s}};t["language-"+e]={pattern:/[\s\S]+/,inside:Prism.languages[e]};var n={};n[a]={pattern:RegExp("(<__[^>]*>)(?:))*\\]\\]>|(?!)".replace(/__/g,(function(){return a})),"i"),lookbehind:!0,greedy:!0,inside:t},Prism.languages.insertBefore("markup","cdata",n)}}),Object.defineProperty(Prism.languages.markup.tag,"addAttribute",{value:function(a,e){Prism.languages.markup.tag.inside["special-attr"].push({pattern:RegExp("(^|[\"'\\s])(?:"+a+")\\s*=\\s*(?:\"[^\"]*\"|'[^']*'|[^\\s'\">=]+(?=[\\s>]))","i"),lookbehind:!0,inside:{"attr-name":/^[^\s=]+/,"attr-value":{pattern:/=[\s\S]+/,inside:{value:{pattern:/(^=\s*(["']|(?!["'])))\S[\s\S]*(?=\2$)/,lookbehind:!0,alias:[e,"language-"+e],inside:Prism.languages[e]},punctuation:[{pattern:/^=/,alias:"attr-equals"},/"|'/]}}}})}}),Prism.languages.html=Prism.languages.markup,Prism.languages.mathml=Prism.languages.markup,Prism.languages.svg=Prism.languages.markup,Prism.languages.xml=Prism.languages.extend("markup",{}),Prism.languages.ssml=Prism.languages.xml,Prism.languages.atom=Prism.languages.xml,Prism.languages.rss=Prism.languages.xml; +!function(e){function n(e,n){return"___"+e.toUpperCase()+n+"___"}Object.defineProperties(e.languages["markup-templating"]={},{buildPlaceholders:{value:function(t,a,r,o){if(t.language===a){var c=t.tokenStack=[];t.code=t.code.replace(r,(function(e){if("function"==typeof o&&!o(e))return e;for(var r,i=c.length;-1!==t.code.indexOf(r=n(a,i));)++i;return c[i]=e,r})),t.grammar=e.languages.markup}}},tokenizePlaceholders:{value:function(t,a){if(t.language===a&&t.tokenStack){t.grammar=e.languages[a];var r=0,o=Object.keys(t.tokenStack);!function c(i){for(var u=0;u=o.length);u++){var g=i[u];if("string"==typeof g||g.content&&"string"==typeof g.content){var l=o[r],s=t.tokenStack[l],f="string"==typeof g?g:g.content,p=n(a,l),k=f.indexOf(p);if(k>-1){++r;var m=f.substring(0,k),d=new e.Token(a,e.tokenize(s,t.grammar),"language-"+a,s),h=f.substring(k+p.length),v=[];m&&v.push.apply(v,c([m])),v.push(d),h&&v.push.apply(v,c([h])),"string"==typeof g?i.splice.apply(i,[u,1].concat(v)):g.content=v}}else g.content&&c(g.content)}return i}(t.tokens)}}}})}(Prism); +!function(s){var e=/(?:"(?:\\(?:\r\n|[\s\S])|[^"\\\r\n])*"|'(?:\\(?:\r\n|[\s\S])|[^'\\\r\n])*')/;s.languages.css={comment:/\/\*[\s\S]*?\*\//,atrule:{pattern:RegExp("@[\\w-](?:[^;{\\s\"']|\\s+(?!\\s)|"+e.source+")*?(?:;|(?=\\s*\\{))"),inside:{rule:/^@[\w-]+/,"selector-function-argument":{pattern:/(\bselector\s*\(\s*(?![\s)]))(?:[^()\s]|\s+(?![\s)])|\((?:[^()]|\([^()]*\))*\))+(?=\s*\))/,lookbehind:!0,alias:"selector"},keyword:{pattern:/(^|[^\w-])(?:and|not|only|or)(?![\w-])/,lookbehind:!0}}},url:{pattern:RegExp("\\burl\\((?:"+e.source+"|(?:[^\\\\\r\n()\"']|\\\\[^])*)\\)","i"),greedy:!0,inside:{function:/^url/i,punctuation:/^\(|\)$/,string:{pattern:RegExp("^"+e.source+"$"),alias:"url"}}},selector:{pattern:RegExp("(^|[{}\\s])[^{}\\s](?:[^{};\"'\\s]|\\s+(?![\\s{])|"+e.source+")*(?=\\s*\\{)"),lookbehind:!0},string:{pattern:e,greedy:!0},property:{pattern:/(^|[^-\w\xA0-\uFFFF])(?!\s)[-_a-z\xA0-\uFFFF](?:(?!\s)[-\w\xA0-\uFFFF])*(?=\s*:)/i,lookbehind:!0},important:/!important\b/i,function:{pattern:/(^|[^-a-z0-9])[-a-z0-9]+(?=\()/i,lookbehind:!0},punctuation:/[(){};:,]/},s.languages.css.atrule.inside.rest=s.languages.css;var t=s.languages.markup;t&&(t.tag.addInlined("style","css"),t.tag.addAttribute("style","css"))}(Prism); +Prism.languages.scss=Prism.languages.extend("css",{comment:{pattern:/(^|[^\\])(?:\/\*[\s\S]*?\*\/|\/\/.*)/,lookbehind:!0},atrule:{pattern:/@[\w-](?:\([^()]+\)|[^()\s]|\s+(?!\s))*?(?=\s+[{;])/,inside:{rule:/@[\w-]+/}},url:/(?:[-a-z]+-)?url(?=\()/i,selector:{pattern:/(?=\S)[^@;{}()]?(?:[^@;{}()\s]|\s+(?!\s)|#\{\$[-\w]+\})+(?=\s*\{(?:\}|\s|[^}][^:{}]*[:{][^}]))/,inside:{parent:{pattern:/&/,alias:"important"},placeholder:/%[-\w]+/,variable:/\$[-\w]+|#\{\$[-\w]+\}/}},property:{pattern:/(?:[-\w]|\$[-\w]|#\{\$[-\w]+\})+(?=\s*:)/,inside:{variable:/\$[-\w]+|#\{\$[-\w]+\}/}}}),Prism.languages.insertBefore("scss","atrule",{keyword:[/@(?:content|debug|each|else(?: if)?|extend|for|forward|function|if|import|include|mixin|return|use|warn|while)\b/i,{pattern:/( )(?:from|through)(?= )/,lookbehind:!0}]}),Prism.languages.insertBefore("scss","important",{variable:/\$[-\w]+|#\{\$[-\w]+\}/}),Prism.languages.insertBefore("scss","function",{"module-modifier":{pattern:/\b(?:as|hide|show|with)\b/i,alias:"keyword"},placeholder:{pattern:/%[-\w]+/,alias:"selector"},statement:{pattern:/\B!(?:default|optional)\b/i,alias:"keyword"},boolean:/\b(?:false|true)\b/,null:{pattern:/\bnull\b/,alias:"keyword"},operator:{pattern:/(\s)(?:[-+*\/%]|[=!]=|<=?|>=?|and|not|or)(?=\s)/,lookbehind:!0}}),Prism.languages.scss.atrule.inside.rest=Prism.languages.scss; +!function(e){e.languages.sass=e.languages.extend("css",{comment:{pattern:/^([ \t]*)\/[\/*].*(?:(?:\r?\n|\r)\1[ \t].+)*/m,lookbehind:!0,greedy:!0}}),e.languages.insertBefore("sass","atrule",{"atrule-line":{pattern:/^(?:[ \t]*)[@+=].+/m,greedy:!0,inside:{atrule:/(?:@[\w-]+|[+=])/}}}),delete e.languages.sass.atrule;var r=/\$[-\w]+|#\{\$[-\w]+\}/,t=[/[+*\/%]|[=!]=|<=?|>=?|\b(?:and|not|or)\b/,{pattern:/(\s)-(?=\s)/,lookbehind:!0}];e.languages.insertBefore("sass","property",{"variable-line":{pattern:/^[ \t]*\$.+/m,greedy:!0,inside:{punctuation:/:/,variable:r,operator:t}},"property-line":{pattern:/^[ \t]*(?:[^:\s]+ *:.*|:[^:\s].*)/m,greedy:!0,inside:{property:[/[^:\s]+(?=\s*:)/,{pattern:/(:)[^:\s]+/,lookbehind:!0}],punctuation:/:/,variable:r,operator:t,important:e.languages.sass.important}}}),delete e.languages.sass.property,delete e.languages.sass.important,e.languages.insertBefore("sass","punctuation",{selector:{pattern:/^([ \t]*)\S(?:,[^,\r\n]+|[^,\r\n]*)(?:,[^,\r\n]+)*(?:,(?:\r?\n|\r)\1[ \t]+\S(?:,[^,\r\n]+|[^,\r\n]*)(?:,[^,\r\n]+)*)*/m,lookbehind:!0,greedy:!0}})}(Prism); +Prism.languages.less=Prism.languages.extend("css",{comment:[/\/\*[\s\S]*?\*\//,{pattern:/(^|[^\\])\/\/.*/,lookbehind:!0}],atrule:{pattern:/@[\w-](?:\((?:[^(){}]|\([^(){}]*\))*\)|[^(){};\s]|\s+(?!\s))*?(?=\s*\{)/,inside:{punctuation:/[:()]/}},selector:{pattern:/(?:@\{[\w-]+\}|[^{};\s@])(?:@\{[\w-]+\}|\((?:[^(){}]|\([^(){}]*\))*\)|[^(){};@\s]|\s+(?!\s))*?(?=\s*\{)/,inside:{variable:/@+[\w-]+/}},property:/(?:@\{[\w-]+\}|[\w-])+(?:\+_?)?(?=\s*:)/,operator:/[+\-*\/]/}),Prism.languages.insertBefore("less","property",{variable:[{pattern:/@[\w-]+\s*:/,inside:{punctuation:/:/}},/@@?[\w-]+/],"mixin-usage":{pattern:/([{;]\s*)[.#](?!\d)[\w-].*?(?=[(;])/,lookbehind:!0,alias:"function"}}); +Prism.languages.javascript=Prism.languages.extend("clike",{"class-name":[Prism.languages.clike["class-name"],{pattern:/(^|[^$\w\xA0-\uFFFF])(?!\s)[_$A-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\.(?:constructor|prototype))/,lookbehind:!0}],keyword:[{pattern:/((?:^|\})\s*)catch\b/,lookbehind:!0},{pattern:/(^|[^.]|\.\.\.\s*)\b(?:as|assert(?=\s*\{)|async(?=\s*(?:function\b|\(|[$\w\xA0-\uFFFF]|$))|await|break|case|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally(?=\s*(?:\{|$))|for|from(?=\s*(?:['"]|$))|function|(?:get|set)(?=\s*(?:[#\[$\w\xA0-\uFFFF]|$))|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)\b/,lookbehind:!0}],function:/#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*(?:\.\s*(?:apply|bind|call)\s*)?\()/,number:{pattern:RegExp("(^|[^\\w$])(?:NaN|Infinity|0[bB][01]+(?:_[01]+)*n?|0[oO][0-7]+(?:_[0-7]+)*n?|0[xX][\\dA-Fa-f]+(?:_[\\dA-Fa-f]+)*n?|\\d+(?:_\\d+)*n|(?:\\d+(?:_\\d+)*(?:\\.(?:\\d+(?:_\\d+)*)?)?|\\.\\d+(?:_\\d+)*)(?:[Ee][+-]?\\d+(?:_\\d+)*)?)(?![\\w$])"),lookbehind:!0},operator:/--|\+\+|\*\*=?|=>|&&=?|\|\|=?|[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|\.{3}|\?\?=?|\?\.?|[~:]/}),Prism.languages.javascript["class-name"][0].pattern=/(\b(?:class|extends|implements|instanceof|interface|new)\s+)[\w.\\]+/,Prism.languages.insertBefore("javascript","keyword",{regex:{pattern:RegExp("((?:^|[^$\\w\\xA0-\\uFFFF.\"'\\])\\s]|\\b(?:return|yield))\\s*)/(?:(?:\\[(?:[^\\]\\\\\r\n]|\\\\.)*\\]|\\\\.|[^/\\\\\\[\r\n])+/[dgimyus]{0,7}|(?:\\[(?:[^[\\]\\\\\r\n]|\\\\.|\\[(?:[^[\\]\\\\\r\n]|\\\\.|\\[(?:[^[\\]\\\\\r\n]|\\\\.)*\\])*\\])*\\]|\\\\.|[^/\\\\\\[\r\n])+/[dgimyus]{0,7}v[dgimyus]{0,7})(?=(?:\\s|/\\*(?:[^*]|\\*(?!/))*\\*/)*(?:$|[\r\n,.;:})\\]]|//))"),lookbehind:!0,greedy:!0,inside:{"regex-source":{pattern:/^(\/)[\s\S]+(?=\/[a-z]*$)/,lookbehind:!0,alias:"language-regex",inside:Prism.languages.regex},"regex-delimiter":/^\/|\/$/,"regex-flags":/^[a-z]+$/}},"function-variable":{pattern:/#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*[=:]\s*(?:async\s*)?(?:\bfunction\b|(?:\((?:[^()]|\([^()]*\))*\)|(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*)\s*=>))/,alias:"function"},parameter:[{pattern:/(function(?:\s+(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*)?\s*\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\))/,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/(^|[^$\w\xA0-\uFFFF])(?!\s)[_$a-z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*=>)/i,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/(\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\)\s*=>)/,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/((?:\b|\s|^)(?!(?:as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)(?![$\w\xA0-\uFFFF]))(?:(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*\s*)\(\s*|\]\s*\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\)\s*\{)/,lookbehind:!0,inside:Prism.languages.javascript}],constant:/\b[A-Z](?:[A-Z_]|\dx?)*\b/}),Prism.languages.insertBefore("javascript","string",{hashbang:{pattern:/^#!.*/,greedy:!0,alias:"comment"},"template-string":{pattern:/`(?:\\[\s\S]|\$\{(?:[^{}]|\{(?:[^{}]|\{[^}]*\})*\})+\}|(?!\$\{)[^\\`])*`/,greedy:!0,inside:{"template-punctuation":{pattern:/^`|`$/,alias:"string"},interpolation:{pattern:/((?:^|[^\\])(?:\\{2})*)\$\{(?:[^{}]|\{(?:[^{}]|\{[^}]*\})*\})+\}/,lookbehind:!0,inside:{"interpolation-punctuation":{pattern:/^\$\{|\}$/,alias:"punctuation"},rest:Prism.languages.javascript}},string:/[\s\S]+/}},"string-property":{pattern:/((?:^|[,{])[ \t]*)(["'])(?:\\(?:\r\n|[\s\S])|(?!\2)[^\\\r\n])*\2(?=\s*:)/m,lookbehind:!0,greedy:!0,alias:"property"}}),Prism.languages.insertBefore("javascript","operator",{"literal-property":{pattern:/((?:^|[,{])[ \t]*)(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*:)/m,lookbehind:!0,alias:"property"}}),Prism.languages.markup&&(Prism.languages.markup.tag.addInlined("script","javascript"),Prism.languages.markup.tag.addAttribute("on(?:abort|blur|change|click|composition(?:end|start|update)|dblclick|error|focus(?:in|out)?|key(?:down|up)|load|mouse(?:down|enter|leave|move|out|over|up)|reset|resize|scroll|select|slotchange|submit|unload|wheel)","javascript")),Prism.languages.js=Prism.languages.javascript; +!function(e){e.languages.typescript=e.languages.extend("javascript",{"class-name":{pattern:/(\b(?:class|extends|implements|instanceof|interface|new|type)\s+)(?!keyof\b)(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?:\s*<(?:[^<>]|<(?:[^<>]|<[^<>]*>)*>)*>)?/,lookbehind:!0,greedy:!0,inside:null},builtin:/\b(?:Array|Function|Promise|any|boolean|console|never|number|string|symbol|unknown)\b/}),e.languages.typescript.keyword.push(/\b(?:abstract|declare|is|keyof|readonly|require)\b/,/\b(?:asserts|infer|interface|module|namespace|type)\b(?=\s*(?:[{_$a-zA-Z\xA0-\uFFFF]|$))/,/\btype\b(?=\s*(?:[\{*]|$))/),delete e.languages.typescript.parameter,delete e.languages.typescript["literal-property"];var s=e.languages.extend("typescript",{});delete s["class-name"],e.languages.typescript["class-name"].inside=s,e.languages.insertBefore("typescript","function",{decorator:{pattern:/@[$\w\xA0-\uFFFF]+/,inside:{at:{pattern:/^@/,alias:"operator"},function:/^[\s\S]+/}},"generic-function":{pattern:/#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*\s*<(?:[^<>]|<(?:[^<>]|<[^<>]*>)*>)*>(?=\s*\()/,greedy:!0,inside:{function:/^#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*/,generic:{pattern:/<[\s\S]+/,alias:"class-name",inside:s}}}}),e.languages.ts=e.languages.typescript}(Prism); +!function(t){var n=t.util.clone(t.languages.javascript),e="(?:\\{*\\.{3}(?:[^{}]|)*\\})";function a(t,n){return t=t.replace(//g,(function(){return"(?:\\s|//.*(?!.)|/\\*(?:[^*]|\\*(?!/))\\*/)"})).replace(//g,(function(){return"(?:\\{(?:\\{(?:\\{[^{}]*\\}|[^{}])*\\}|[^{}])*\\})"})).replace(//g,(function(){return e})),RegExp(t,n)}e=a(e).source,t.languages.jsx=t.languages.extend("markup",n),t.languages.jsx.tag.pattern=a("+(?:[\\w.:$-]+(?:=(?:\"(?:\\\\[^]|[^\\\\\"])*\"|'(?:\\\\[^]|[^\\\\'])*'|[^\\s{'\"/>=]+|))?|))**/?)?>"),t.languages.jsx.tag.inside.tag.pattern=/^<\/?[^\s>\/]*/,t.languages.jsx.tag.inside["attr-value"].pattern=/=(?!\{)(?:"(?:\\[\s\S]|[^\\"])*"|'(?:\\[\s\S]|[^\\'])*'|[^\s'">]+)/,t.languages.jsx.tag.inside.tag.inside["class-name"]=/^[A-Z]\w*(?:\.[A-Z]\w*)*$/,t.languages.jsx.tag.inside.comment=n.comment,t.languages.insertBefore("inside","attr-name",{spread:{pattern:a(""),inside:t.languages.jsx}},t.languages.jsx.tag),t.languages.insertBefore("inside","special-attr",{script:{pattern:a("="),alias:"language-javascript",inside:{"script-punctuation":{pattern:/^=(?=\{)/,alias:"punctuation"},rest:t.languages.jsx}}},t.languages.jsx.tag);var s=function(t){return t?"string"==typeof t?t:"string"==typeof t.content?t.content:t.content.map(s).join(""):""},g=function(n){for(var e=[],a=0;a0&&e[e.length-1].tagName===s(o.content[0].content[1])&&e.pop():"/>"===o.content[o.content.length-1].content||e.push({tagName:s(o.content[0].content[1]),openedBraces:0}):e.length>0&&"punctuation"===o.type&&"{"===o.content?e[e.length-1].openedBraces++:e.length>0&&e[e.length-1].openedBraces>0&&"punctuation"===o.type&&"}"===o.content?e[e.length-1].openedBraces--:i=!0),(i||"string"==typeof o)&&e.length>0&&0===e[e.length-1].openedBraces){var r=s(o);a0&&("string"==typeof n[a-1]||"plain-text"===n[a-1].type)&&(r=s(n[a-1])+r,n.splice(a-1,1),a--),n[a]=new t.Token("plain-text",r,null,r)}o.content&&"string"!=typeof o.content&&g(o.content)}};t.hooks.add("after-tokenize",(function(t){"jsx"!==t.language&&"tsx"!==t.language||g(t.tokens)}))}(Prism); +!function(e){var a=e.util.clone(e.languages.typescript);e.languages.tsx=e.languages.extend("jsx",a),delete e.languages.tsx.parameter,delete e.languages.tsx["literal-property"];var t=e.languages.tsx.tag;t.pattern=RegExp("(^|[^\\w$]|(?=]=?|[!=]=?=?|--?|\+\+?|&&?|\|\|?|[?*/~^%]/,punctuation:/[{}[\];(),.:]/}; +Prism.languages.c=Prism.languages.extend("clike",{comment:{pattern:/\/\/(?:[^\r\n\\]|\\(?:\r\n?|\n|(?![\r\n])))*|\/\*[\s\S]*?(?:\*\/|$)/,greedy:!0},string:{pattern:/"(?:\\(?:\r\n|[\s\S])|[^"\\\r\n])*"/,greedy:!0},"class-name":{pattern:/(\b(?:enum|struct)\s+(?:__attribute__\s*\(\([\s\S]*?\)\)\s*)?)\w+|\b[a-z]\w*_t\b/,lookbehind:!0},keyword:/\b(?:_Alignas|_Alignof|_Atomic|_Bool|_Complex|_Generic|_Imaginary|_Noreturn|_Static_assert|_Thread_local|__attribute__|asm|auto|break|case|char|const|continue|default|do|double|else|enum|extern|float|for|goto|if|inline|int|long|register|return|short|signed|sizeof|static|struct|switch|typedef|typeof|union|unsigned|void|volatile|while)\b/,function:/\b[a-z_]\w*(?=\s*\()/i,number:/(?:\b0x(?:[\da-f]+(?:\.[\da-f]*)?|\.[\da-f]+)(?:p[+-]?\d+)?|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:e[+-]?\d+)?)[ful]{0,4}/i,operator:/>>=?|<<=?|->|([-+&|:])\1|[?:~]|[-+*/%&|^!=<>]=?/}),Prism.languages.insertBefore("c","string",{char:{pattern:/'(?:\\(?:\r\n|[\s\S])|[^'\\\r\n]){0,32}'/,greedy:!0}}),Prism.languages.insertBefore("c","string",{macro:{pattern:/(^[\t ]*)#\s*[a-z](?:[^\r\n\\/]|\/(?!\*)|\/\*(?:[^*]|\*(?!\/))*\*\/|\\(?:\r\n|[\s\S]))*/im,lookbehind:!0,greedy:!0,alias:"property",inside:{string:[{pattern:/^(#\s*include\s*)<[^>]+>/,lookbehind:!0},Prism.languages.c.string],char:Prism.languages.c.char,comment:Prism.languages.c.comment,"macro-name":[{pattern:/(^#\s*define\s+)\w+\b(?!\()/i,lookbehind:!0},{pattern:/(^#\s*define\s+)\w+\b(?=\()/i,lookbehind:!0,alias:"function"}],directive:{pattern:/^(#\s*)[a-z]+/,lookbehind:!0,alias:"keyword"},"directive-hash":/^#/,punctuation:/##|\\(?=[\r\n])/,expression:{pattern:/\S[\s\S]*/,inside:Prism.languages.c}}}}),Prism.languages.insertBefore("c","function",{constant:/\b(?:EOF|NULL|SEEK_CUR|SEEK_END|SEEK_SET|__DATE__|__FILE__|__LINE__|__TIMESTAMP__|__TIME__|__func__|stderr|stdin|stdout)\b/}),delete Prism.languages.c.boolean; +!function(e){var t=/\b(?:alignas|alignof|asm|auto|bool|break|case|catch|char|char16_t|char32_t|char8_t|class|co_await|co_return|co_yield|compl|concept|const|const_cast|consteval|constexpr|constinit|continue|decltype|default|delete|do|double|dynamic_cast|else|enum|explicit|export|extern|final|float|for|friend|goto|if|import|inline|int|int16_t|int32_t|int64_t|int8_t|long|module|mutable|namespace|new|noexcept|nullptr|operator|override|private|protected|public|register|reinterpret_cast|requires|return|short|signed|sizeof|static|static_assert|static_cast|struct|switch|template|this|thread_local|throw|try|typedef|typeid|typename|uint16_t|uint32_t|uint64_t|uint8_t|union|unsigned|using|virtual|void|volatile|wchar_t|while)\b/,n="\\b(?!)\\w+(?:\\s*\\.\\s*\\w+)*\\b".replace(//g,(function(){return t.source}));e.languages.cpp=e.languages.extend("c",{"class-name":[{pattern:RegExp("(\\b(?:class|concept|enum|struct|typename)\\s+)(?!)\\w+".replace(//g,(function(){return t.source}))),lookbehind:!0},/\b[A-Z]\w*(?=\s*::\s*\w+\s*\()/,/\b[A-Z_]\w*(?=\s*::\s*~\w+\s*\()/i,/\b\w+(?=\s*<(?:[^<>]|<(?:[^<>]|<[^<>]*>)*>)*>\s*::\s*\w+\s*\()/],keyword:t,number:{pattern:/(?:\b0b[01']+|\b0x(?:[\da-f']+(?:\.[\da-f']*)?|\.[\da-f']+)(?:p[+-]?[\d']+)?|(?:\b[\d']+(?:\.[\d']*)?|\B\.[\d']+)(?:e[+-]?[\d']+)?)[ful]{0,4}/i,greedy:!0},operator:/>>=?|<<=?|->|--|\+\+|&&|\|\||[?:~]|<=>|[-+*/%&|^!=<>]=?|\b(?:and|and_eq|bitand|bitor|not|not_eq|or|or_eq|xor|xor_eq)\b/,boolean:/\b(?:false|true)\b/}),e.languages.insertBefore("cpp","string",{module:{pattern:RegExp('(\\b(?:import|module)\\s+)(?:"(?:\\\\(?:\r\n|[^])|[^"\\\\\r\n])*"|<[^<>\r\n]*>|'+"(?:\\s*:\\s*)?|:\\s*".replace(//g,(function(){return n}))+")"),lookbehind:!0,greedy:!0,inside:{string:/^[<"][\s\S]+/,operator:/:/,punctuation:/\./}},"raw-string":{pattern:/R"([^()\\ ]{0,16})\([\s\S]*?\)\1"/,alias:"string",greedy:!0}}),e.languages.insertBefore("cpp","keyword",{"generic-function":{pattern:/\b(?!operator\b)[a-z_]\w*\s*<(?:[^<>]|<[^<>]*>)*>(?=\s*\()/i,inside:{function:/^\w+/,generic:{pattern:/<[\s\S]+/,alias:"class-name",inside:e.languages.cpp}}}}),e.languages.insertBefore("cpp","operator",{"double-colon":{pattern:/::/,alias:"punctuation"}}),e.languages.insertBefore("cpp","class-name",{"base-clause":{pattern:/(\b(?:class|struct)\s+\w+\s*:\s*)[^;{}"'\s]+(?:\s+[^;{}"'\s]+)*(?=\s*[;{])/,lookbehind:!0,greedy:!0,inside:e.languages.extend("cpp",{})}}),e.languages.insertBefore("inside","double-colon",{"class-name":/\b[a-z_]\w*\b(?!\s*::)/i},e.languages.cpp["base-clause"])}(Prism); +!function(e){for(var a="/\\*(?:[^*/]|\\*(?!/)|/(?!\\*)|)*\\*/",t=0;t<2;t++)a=a.replace(//g,(function(){return a}));a=a.replace(//g,(function(){return"[^\\s\\S]"})),e.languages.rust={comment:[{pattern:RegExp("(^|[^\\\\])"+a),lookbehind:!0,greedy:!0},{pattern:/(^|[^\\:])\/\/.*/,lookbehind:!0,greedy:!0}],string:{pattern:/b?"(?:\\[\s\S]|[^\\"])*"|b?r(#*)"(?:[^"]|"(?!\1))*"\1/,greedy:!0},char:{pattern:/b?'(?:\\(?:x[0-7][\da-fA-F]|u\{(?:[\da-fA-F]_*){1,6}\}|.)|[^\\\r\n\t'])'/,greedy:!0},attribute:{pattern:/#!?\[(?:[^\[\]"]|"(?:\\[\s\S]|[^\\"])*")*\]/,greedy:!0,alias:"attr-name",inside:{string:null}},"closure-params":{pattern:/([=(,:]\s*|\bmove\s*)\|[^|]*\||\|[^|]*\|(?=\s*(?:\{|->))/,lookbehind:!0,greedy:!0,inside:{"closure-punctuation":{pattern:/^\||\|$/,alias:"punctuation"},rest:null}},"lifetime-annotation":{pattern:/'\w+/,alias:"symbol"},"fragment-specifier":{pattern:/(\$\w+:)[a-z]+/,lookbehind:!0,alias:"punctuation"},variable:/\$\w+/,"function-definition":{pattern:/(\bfn\s+)\w+/,lookbehind:!0,alias:"function"},"type-definition":{pattern:/(\b(?:enum|struct|trait|type|union)\s+)\w+/,lookbehind:!0,alias:"class-name"},"module-declaration":[{pattern:/(\b(?:crate|mod)\s+)[a-z][a-z_\d]*/,lookbehind:!0,alias:"namespace"},{pattern:/(\b(?:crate|self|super)\s*)::\s*[a-z][a-z_\d]*\b(?:\s*::(?:\s*[a-z][a-z_\d]*\s*::)*)?/,lookbehind:!0,alias:"namespace",inside:{punctuation:/::/}}],keyword:[/\b(?:Self|abstract|as|async|await|become|box|break|const|continue|crate|do|dyn|else|enum|extern|final|fn|for|if|impl|in|let|loop|macro|match|mod|move|mut|override|priv|pub|ref|return|self|static|struct|super|trait|try|type|typeof|union|unsafe|unsized|use|virtual|where|while|yield)\b/,/\b(?:bool|char|f(?:32|64)|[ui](?:8|16|32|64|128|size)|str)\b/],function:/\b[a-z_]\w*(?=\s*(?:::\s*<|\())/,macro:{pattern:/\b\w+!/,alias:"property"},constant:/\b[A-Z_][A-Z_\d]+\b/,"class-name":/\b[A-Z]\w*\b/,namespace:{pattern:/(?:\b[a-z][a-z_\d]*\s*::\s*)*\b[a-z][a-z_\d]*\s*::(?!\s*<)/,inside:{punctuation:/::/}},number:/\b(?:0x[\dA-Fa-f](?:_?[\dA-Fa-f])*|0o[0-7](?:_?[0-7])*|0b[01](?:_?[01])*|(?:(?:\d(?:_?\d)*)?\.)?\d(?:_?\d)*(?:[Ee][+-]?\d+)?)(?:_?(?:f32|f64|[iu](?:8|16|32|64|size)?))?\b/,boolean:/\b(?:false|true)\b/,punctuation:/->|\.\.=|\.{1,3}|::|[{}[\];(),:]/,operator:/[-+*\/%!^]=?|=[=>]?|&[&=]?|\|[|=]?|<>?=?|[@?]/},e.languages.rust["closure-params"].inside.rest=e.languages.rust,e.languages.rust.attribute.inside.string=e.languages.rust.string}(Prism); +Prism.languages.go=Prism.languages.extend("clike",{string:{pattern:/(^|[^\\])"(?:\\.|[^"\\\r\n])*"|`[^`]*`/,lookbehind:!0,greedy:!0},keyword:/\b(?:break|case|chan|const|continue|default|defer|else|fallthrough|for|func|go(?:to)?|if|import|interface|map|package|range|return|select|struct|switch|type|var)\b/,boolean:/\b(?:_|false|iota|nil|true)\b/,number:[/\b0(?:b[01_]+|o[0-7_]+)i?\b/i,/\b0x(?:[a-f\d_]+(?:\.[a-f\d_]*)?|\.[a-f\d_]+)(?:p[+-]?\d+(?:_\d+)*)?i?(?!\w)/i,/(?:\b\d[\d_]*(?:\.[\d_]*)?|\B\.\d[\d_]*)(?:e[+-]?[\d_]+)?i?(?!\w)/i],operator:/[*\/%^!=]=?|\+[=+]?|-[=-]?|\|[=|]?|&(?:=|&|\^=?)?|>(?:>=?|=)?|<(?:<=?|=|-)?|:=|\.\.\./,builtin:/\b(?:append|bool|byte|cap|close|complex|complex(?:64|128)|copy|delete|error|float(?:32|64)|u?int(?:8|16|32|64)?|imag|len|make|new|panic|print(?:ln)?|real|recover|rune|string|uintptr)\b/}),Prism.languages.insertBefore("go","string",{char:{pattern:/'(?:\\.|[^'\\\r\n]){0,10}'/,greedy:!0}}),delete Prism.languages.go["class-name"]; +!function(e){function n(e){return function(){return e}}var r=/\b(?:align|allowzero|and|anyframe|anytype|asm|async|await|break|cancel|catch|comptime|const|continue|defer|else|enum|errdefer|error|export|extern|fn|for|if|inline|linksection|nakedcc|noalias|nosuspend|null|or|orelse|packed|promise|pub|resume|return|stdcallcc|struct|suspend|switch|test|threadlocal|try|undefined|union|unreachable|usingnamespace|var|volatile|while)\b/,a="\\b(?!"+r.source+")(?!\\d)\\w+\\b",o="align\\s*\\((?:[^()]|\\([^()]*\\))*\\)",s="(?!\\s)(?:!?\\s*(?:"+"(?:\\?|\\bpromise->|(?:\\[[^[\\]]*\\]|\\*(?!\\*)|\\*\\*)(?:\\s*|\\s*const\\b|\\s*volatile\\b|\\s*allowzero\\b)*)".replace(//g,n(o))+"\\s*)*"+"(?:\\bpromise\\b|(?:\\berror\\.)?(?:\\.)*(?!\\s+))".replace(//g,n(a))+")+";e.languages.zig={comment:[{pattern:/\/\/[/!].*/,alias:"doc-comment"},/\/{2}.*/],string:[{pattern:/(^|[^\\@])c?"(?:[^"\\\r\n]|\\.)*"/,lookbehind:!0,greedy:!0},{pattern:/([\r\n])([ \t]+c?\\{2}).*(?:(?:\r\n?|\n)\2.*)*/,lookbehind:!0,greedy:!0}],char:{pattern:/(^|[^\\])'(?:[^'\\\r\n]|[\uD800-\uDFFF]{2}|\\(?:.|x[a-fA-F\d]{2}|u\{[a-fA-F\d]{1,6}\}))'/,lookbehind:!0,greedy:!0},builtin:/\B@(?!\d)\w+(?=\s*\()/,label:{pattern:/(\b(?:break|continue)\s*:\s*)\w+\b|\b(?!\d)\w+\b(?=\s*:\s*(?:\{|while\b))/,lookbehind:!0},"class-name":[/\b(?!\d)\w+(?=\s*=\s*(?:(?:extern|packed)\s+)?(?:enum|struct|union)\s*[({])/,{pattern:RegExp("(:\\s*)(?=\\s*(?:\\s*)?[=;,)])|(?=\\s*(?:\\s*)?\\{)".replace(//g,n(s)).replace(//g,n(o))),lookbehind:!0,inside:null},{pattern:RegExp("(\\)\\s*)(?=\\s*(?:\\s*)?;)".replace(//g,n(s)).replace(//g,n(o))),lookbehind:!0,inside:null}],"builtin-type":{pattern:/\b(?:anyerror|bool|c_u?(?:int|long|longlong|short)|c_longdouble|c_void|comptime_(?:float|int)|f(?:16|32|64|128)|[iu](?:8|16|32|64|128|size)|noreturn|type|void)\b/,alias:"keyword"},keyword:r,function:/\b(?!\d)\w+(?=\s*\()/,number:/\b(?:0b[01]+|0o[0-7]+|0x[a-fA-F\d]+(?:\.[a-fA-F\d]*)?(?:[pP][+-]?[a-fA-F\d]+)?|\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)\b/,boolean:/\b(?:false|true)\b/,operator:/\.[*?]|\.{2,3}|[-=]>|\*\*|\+\+|\|\||(?:<<|>>|[-+*]%|[-+*/%^&|<>!=])=?|[?~]/,punctuation:/[.:,;(){}[\]]/},e.languages.zig["class-name"].forEach((function(n){null===n.inside&&(n.inside=e.languages.zig)}))}(Prism); +Prism.languages.swift={comment:{pattern:/(^|[^\\:])(?:\/\/.*|\/\*(?:[^/*]|\/(?!\*)|\*(?!\/)|\/\*(?:[^*]|\*(?!\/))*\*\/)*\*\/)/,lookbehind:!0,greedy:!0},"string-literal":[{pattern:RegExp('(^|[^"#])(?:"(?:\\\\(?:\\((?:[^()]|\\([^()]*\\))*\\)|\r\n|[^(])|[^\\\\\r\n"])*"|"""(?:\\\\(?:\\((?:[^()]|\\([^()]*\\))*\\)|[^(])|[^\\\\"]|"(?!""))*""")(?!["#])'),lookbehind:!0,greedy:!0,inside:{interpolation:{pattern:/(\\\()(?:[^()]|\([^()]*\))*(?=\))/,lookbehind:!0,inside:null},"interpolation-punctuation":{pattern:/^\)|\\\($/,alias:"punctuation"},punctuation:/\\(?=[\r\n])/,string:/[\s\S]+/}},{pattern:RegExp('(^|[^"#])(#+)(?:"(?:\\\\(?:#+\\((?:[^()]|\\([^()]*\\))*\\)|\r\n|[^#])|[^\\\\\r\n])*?"|"""(?:\\\\(?:#+\\((?:[^()]|\\([^()]*\\))*\\)|[^#])|[^\\\\])*?""")\\2'),lookbehind:!0,greedy:!0,inside:{interpolation:{pattern:/(\\#+\()(?:[^()]|\([^()]*\))*(?=\))/,lookbehind:!0,inside:null},"interpolation-punctuation":{pattern:/^\)|\\#+\($/,alias:"punctuation"},string:/[\s\S]+/}}],directive:{pattern:RegExp("#(?:(?:elseif|if)\\b(?:[ \t]*(?:![ \t]*)?(?:\\b\\w+\\b(?:[ \t]*\\((?:[^()]|\\([^()]*\\))*\\))?|\\((?:[^()]|\\([^()]*\\))*\\))(?:[ \t]*(?:&&|\\|\\|))?)+|(?:else|endif)\\b)"),alias:"property",inside:{"directive-name":/^#\w+/,boolean:/\b(?:false|true)\b/,number:/\b\d+(?:\.\d+)*\b/,operator:/!|&&|\|\||[<>]=?/,punctuation:/[(),]/}},literal:{pattern:/#(?:colorLiteral|column|dsohandle|file(?:ID|Literal|Path)?|function|imageLiteral|line)\b/,alias:"constant"},"other-directive":{pattern:/#\w+\b/,alias:"property"},attribute:{pattern:/@\w+/,alias:"atrule"},"function-definition":{pattern:/(\bfunc\s+)\w+/,lookbehind:!0,alias:"function"},label:{pattern:/\b(break|continue)\s+\w+|\b[a-zA-Z_]\w*(?=\s*:\s*(?:for|repeat|while)\b)/,lookbehind:!0,alias:"important"},keyword:/\b(?:Any|Protocol|Self|Type|actor|as|assignment|associatedtype|associativity|async|await|break|case|catch|class|continue|convenience|default|defer|deinit|didSet|do|dynamic|else|enum|extension|fallthrough|fileprivate|final|for|func|get|guard|higherThan|if|import|in|indirect|infix|init|inout|internal|is|isolated|lazy|left|let|lowerThan|mutating|none|nonisolated|nonmutating|open|operator|optional|override|postfix|precedencegroup|prefix|private|protocol|public|repeat|required|rethrows|return|right|safe|self|set|some|static|struct|subscript|super|switch|throw|throws|try|typealias|unowned|unsafe|var|weak|where|while|willSet)\b/,boolean:/\b(?:false|true)\b/,nil:{pattern:/\bnil\b/,alias:"constant"},"short-argument":/\$\d+\b/,omit:{pattern:/\b_\b/,alias:"keyword"},number:/\b(?:[\d_]+(?:\.[\de_]+)?|0x[a-f0-9_]+(?:\.[a-f0-9p_]+)?|0b[01_]+|0o[0-7_]+)\b/i,"class-name":/\b[A-Z](?:[A-Z_\d]*[a-z]\w*)?\b/,function:/\b[a-z_]\w*(?=\s*\()/i,constant:/\b(?:[A-Z_]{2,}|k[A-Z][A-Za-z_]+)\b/,operator:/[-+*/%=!<>&|^~?]+|\.[.\-+*/%=!<>&|^~?]+/,punctuation:/[{}[\]();,.:\\]/},Prism.languages.swift["string-literal"].forEach((function(e){e.inside.interpolation.inside=Prism.languages.swift})); +Prism.languages.objectivec=Prism.languages.extend("c",{string:{pattern:/@?"(?:\\(?:\r\n|[\s\S])|[^"\\\r\n])*"/,greedy:!0},keyword:/\b(?:asm|auto|break|case|char|const|continue|default|do|double|else|enum|extern|float|for|goto|if|in|inline|int|long|register|return|self|short|signed|sizeof|static|struct|super|switch|typedef|typeof|union|unsigned|void|volatile|while)\b|(?:@interface|@end|@implementation|@protocol|@class|@public|@protected|@private|@property|@try|@catch|@finally|@throw|@synthesize|@dynamic|@selector)\b/,operator:/-[->]?|\+\+?|!=?|<>?=?|==?|&&?|\|\|?|[~^%?*\/@]/}),delete Prism.languages.objectivec["class-name"],Prism.languages.objc=Prism.languages.objectivec; +!function(e){var n=/\b(?:abstract|assert|boolean|break|byte|case|catch|char|class|const|continue|default|do|double|else|enum|exports|extends|final|finally|float|for|goto|if|implements|import|instanceof|int|interface|long|module|native|new|non-sealed|null|open|opens|package|permits|private|protected|provides|public|record(?!\s*[(){}[\]<>=%~.:,;?+\-*/&|^])|requires|return|sealed|short|static|strictfp|super|switch|synchronized|this|throw|throws|to|transient|transitive|try|uses|var|void|volatile|while|with|yield)\b/,t="(?:[a-z]\\w*\\s*\\.\\s*)*(?:[A-Z]\\w*\\s*\\.\\s*)*",s={pattern:RegExp("(^|[^\\w.])"+t+"[A-Z](?:[\\d_A-Z]*[a-z]\\w*)?\\b"),lookbehind:!0,inside:{namespace:{pattern:/^[a-z]\w*(?:\s*\.\s*[a-z]\w*)*(?:\s*\.)?/,inside:{punctuation:/\./}},punctuation:/\./}};e.languages.java=e.languages.extend("clike",{string:{pattern:/(^|[^\\])"(?:\\.|[^"\\\r\n])*"/,lookbehind:!0,greedy:!0},"class-name":[s,{pattern:RegExp("(^|[^\\w.])"+t+"[A-Z]\\w*(?=\\s+\\w+\\s*[;,=()]|\\s*(?:\\[[\\s,]*\\]\\s*)?::\\s*new\\b)"),lookbehind:!0,inside:s.inside},{pattern:RegExp("(\\b(?:class|enum|extends|implements|instanceof|interface|new|record|throws)\\s+)"+t+"[A-Z]\\w*\\b"),lookbehind:!0,inside:s.inside}],keyword:n,function:[e.languages.clike.function,{pattern:/(::\s*)[a-z_]\w*/,lookbehind:!0}],number:/\b0b[01][01_]*L?\b|\b0x(?:\.[\da-f_p+-]+|[\da-f_]+(?:\.[\da-f_p+-]+)?)\b|(?:\b\d[\d_]*(?:\.[\d_]*)?|\B\.\d[\d_]*)(?:e[+-]?\d[\d_]*)?[dfl]?/i,operator:{pattern:/(^|[^.])(?:<<=?|>>>?=?|->|--|\+\+|&&|\|\||::|[?:~]|[-+*/%&|^!=<>]=?)/m,lookbehind:!0},constant:/\b[A-Z][A-Z_\d]+\b/}),e.languages.insertBefore("java","string",{"triple-quoted-string":{pattern:/"""[ \t]*[\r\n](?:(?:"|"")?(?:\\.|[^"\\]))*"""/,greedy:!0,alias:"string"},char:{pattern:/'(?:\\.|[^'\\\r\n]){1,6}'/,greedy:!0}}),e.languages.insertBefore("java","class-name",{annotation:{pattern:/(^|[^.])@\w+(?:\s*\.\s*\w+)*/,lookbehind:!0,alias:"punctuation"},generics:{pattern:/<(?:[\w\s,.?]|&(?!&)|<(?:[\w\s,.?]|&(?!&)|<(?:[\w\s,.?]|&(?!&)|<(?:[\w\s,.?]|&(?!&))*>)*>)*>)*>/,inside:{"class-name":s,keyword:n,punctuation:/[<>(),.:]/,operator:/[?&|]/}},import:[{pattern:RegExp("(\\bimport\\s+)"+t+"(?:[A-Z]\\w*|\\*)(?=\\s*;)"),lookbehind:!0,inside:{namespace:s.inside.namespace,punctuation:/\./,operator:/\*/,"class-name":/\w+/}},{pattern:RegExp("(\\bimport\\s+static\\s+)"+t+"(?:\\w+|\\*)(?=\\s*;)"),lookbehind:!0,alias:"static",inside:{namespace:s.inside.namespace,static:/\b\w+$/,punctuation:/\./,operator:/\*/,"class-name":/\w+/}}],namespace:{pattern:RegExp("(\\b(?:exports|import(?:\\s+static)?|module|open|opens|package|provides|requires|to|transitive|uses|with)\\s+)(?!)[a-z]\\w*(?:\\.[a-z]\\w*)*\\.?".replace(//g,(function(){return n.source}))),lookbehind:!0,inside:{punctuation:/\./}}})}(Prism); +!function(n){n.languages.kotlin=n.languages.extend("clike",{keyword:{pattern:/(^|[^.])\b(?:abstract|actual|annotation|as|break|by|catch|class|companion|const|constructor|continue|crossinline|data|do|dynamic|else|enum|expect|external|final|finally|for|fun|get|if|import|in|infix|init|inline|inner|interface|internal|is|lateinit|noinline|null|object|open|operator|out|override|package|private|protected|public|reified|return|sealed|set|super|suspend|tailrec|this|throw|to|try|typealias|val|var|vararg|when|where|while)\b/,lookbehind:!0},function:[{pattern:/(?:`[^\r\n`]+`|\b\w+)(?=\s*\()/,greedy:!0},{pattern:/(\.)(?:`[^\r\n`]+`|\w+)(?=\s*\{)/,lookbehind:!0,greedy:!0}],number:/\b(?:0[xX][\da-fA-F]+(?:_[\da-fA-F]+)*|0[bB][01]+(?:_[01]+)*|\d+(?:_\d+)*(?:\.\d+(?:_\d+)*)?(?:[eE][+-]?\d+(?:_\d+)*)?[fFL]?)\b/,operator:/\+[+=]?|-[-=>]?|==?=?|!(?:!|==?)?|[\/*%<>]=?|[?:]:?|\.\.|&&|\|\||\b(?:and|inv|or|shl|shr|ushr|xor)\b/}),delete n.languages.kotlin["class-name"];var e={"interpolation-punctuation":{pattern:/^\$\{?|\}$/,alias:"punctuation"},expression:{pattern:/[\s\S]+/,inside:n.languages.kotlin}};n.languages.insertBefore("kotlin","string",{"string-literal":[{pattern:/"""(?:[^$]|\$(?:(?!\{)|\{[^{}]*\}))*?"""/,alias:"multiline",inside:{interpolation:{pattern:/\$(?:[a-z_]\w*|\{[^{}]*\})/i,inside:e},string:/[\s\S]+/}},{pattern:/"(?:[^"\\\r\n$]|\\.|\$(?:(?!\{)|\{[^{}]*\}))*"/,alias:"singleline",inside:{interpolation:{pattern:/((?:^|[^\\])(?:\\{2})*)\$(?:[a-z_]\w*|\{[^{}]*\})/i,lookbehind:!0,inside:e},string:/[\s\S]+/}}],char:{pattern:/'(?:[^'\\\r\n]|\\(?:.|u[a-fA-F0-9]{0,4}))'/,greedy:!0}}),delete n.languages.kotlin.string,n.languages.insertBefore("kotlin","keyword",{annotation:{pattern:/\B@(?:\w+:)?(?:[A-Z]\w*|\[[^\]]+\])/,alias:"builtin"}}),n.languages.insertBefore("kotlin","function",{label:{pattern:/\b\w+@|@\w+\b/,alias:"symbol"}}),n.languages.kt=n.languages.kotlin,n.languages.kts=n.languages.kotlin}(Prism); +Prism.languages.scala=Prism.languages.extend("java",{"triple-quoted-string":{pattern:/"""[\s\S]*?"""/,greedy:!0,alias:"string"},string:{pattern:/("|')(?:\\.|(?!\1)[^\\\r\n])*\1/,greedy:!0},keyword:/<-|=>|\b(?:abstract|case|catch|class|def|derives|do|else|enum|extends|extension|final|finally|for|forSome|given|if|implicit|import|infix|inline|lazy|match|new|null|object|opaque|open|override|package|private|protected|return|sealed|self|super|this|throw|trait|transparent|try|type|using|val|var|while|with|yield)\b/,number:/\b0x(?:[\da-f]*\.)?[\da-f]+|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:e\d+)?[dfl]?/i,builtin:/\b(?:Any|AnyRef|AnyVal|Boolean|Byte|Char|Double|Float|Int|Long|Nothing|Short|String|Unit)\b/,symbol:/'[^\d\s\\]\w*/}),Prism.languages.insertBefore("scala","triple-quoted-string",{"string-interpolation":{pattern:/\b[a-z]\w*(?:"""(?:[^$]|\$(?:[^{]|\{(?:[^{}]|\{[^{}]*\})*\}))*?"""|"(?:[^$"\r\n]|\$(?:[^{]|\{(?:[^{}]|\{[^{}]*\})*\}))*")/i,greedy:!0,inside:{id:{pattern:/^\w+/,greedy:!0,alias:"function"},escape:{pattern:/\\\$"|\$[$"]/,greedy:!0,alias:"symbol"},interpolation:{pattern:/\$(?:\w+|\{(?:[^{}]|\{[^{}]*\})*\})/,greedy:!0,inside:{punctuation:/^\$\{?|\}$/,expression:{pattern:/[\s\S]+/,inside:Prism.languages.scala}}},string:/[\s\S]+/}}}),delete Prism.languages.scala["class-name"],delete Prism.languages.scala.function,delete Prism.languages.scala.constant; +!function(e){var n={pattern:/((?:^|[^\\$])(?:\\{2})*)\$(?:\w+|\{[^{}]*\})/,lookbehind:!0,inside:{"interpolation-punctuation":{pattern:/^\$\{?|\}$/,alias:"punctuation"},expression:{pattern:/[\s\S]+/,inside:null}}};e.languages.groovy=e.languages.extend("clike",{string:{pattern:/'''(?:[^\\]|\\[\s\S])*?'''|'(?:\\.|[^\\'\r\n])*'/,greedy:!0},keyword:/\b(?:abstract|as|assert|boolean|break|byte|case|catch|char|class|const|continue|def|default|do|double|else|enum|extends|final|finally|float|for|goto|if|implements|import|in|instanceof|int|interface|long|native|new|package|private|protected|public|return|short|static|strictfp|super|switch|synchronized|this|throw|throws|trait|transient|try|void|volatile|while)\b/,number:/\b(?:0b[01_]+|0x[\da-f_]+(?:\.[\da-f_p\-]+)?|[\d_]+(?:\.[\d_]+)?(?:e[+-]?\d+)?)[glidf]?\b/i,operator:{pattern:/(^|[^.])(?:~|==?~?|\?[.:]?|\*(?:[.=]|\*=?)?|\.[@&]|\.\.<|\.\.(?!\.)|-[-=>]?|\+[+=]?|!=?|<(?:<=?|=>?)?|>(?:>>?=?|=)?|&[&=]?|\|[|=]?|\/=?|\^=?|%=?)/,lookbehind:!0},punctuation:/\.+|[{}[\];(),:$]/}),e.languages.insertBefore("groovy","string",{shebang:{pattern:/#!.+/,alias:"comment",greedy:!0},"interpolation-string":{pattern:/"""(?:[^\\]|\\[\s\S])*?"""|(["/])(?:\\.|(?!\1)[^\\\r\n])*\1|\$\/(?:[^/$]|\$(?:[/$]|(?![/$]))|\/(?!\$))*\/\$/,greedy:!0,inside:{interpolation:n,string:/[\s\S]+/}}}),e.languages.insertBefore("groovy","punctuation",{"spock-block":/\b(?:and|cleanup|expect|given|setup|then|when|where):/}),e.languages.insertBefore("groovy","function",{annotation:{pattern:/(^|[^.])@\w+/,lookbehind:!0,alias:"punctuation"}}),n.inside.expression.inside=e.languages.groovy}(Prism); +Prism.languages.clojure={comment:{pattern:/;.*/,greedy:!0},string:{pattern:/"(?:[^"\\]|\\.)*"/,greedy:!0},char:/\\\w+/,symbol:{pattern:/(^|[\s()\[\]{},])::?[\w*+!?'<>=/.-]+/,lookbehind:!0},keyword:{pattern:/(\()(?:-|->|->>|\.|\.\.|\*|\/|\+|<|<=|=|==|>|>=|accessor|agent|agent-errors|aget|alength|all-ns|alter|and|append-child|apply|array-map|aset|aset-boolean|aset-byte|aset-char|aset-double|aset-float|aset-int|aset-long|aset-short|assert|assoc|await|await-for|bean|binding|bit-and|bit-not|bit-or|bit-shift-left|bit-shift-right|bit-xor|boolean|branch\?|butlast|byte|cast|char|children|class|clear-agent-errors|comment|commute|comp|comparator|complement|concat|cond|conj|cons|constantly|construct-proxy|contains\?|count|create-ns|create-struct|cycle|dec|declare|def|def-|definline|definterface|defmacro|defmethod|defmulti|defn|defn-|defonce|defproject|defprotocol|defrecord|defstruct|deftype|deref|difference|disj|dissoc|distinct|do|doall|doc|dorun|doseq|dosync|dotimes|doto|double|down|drop|drop-while|edit|end\?|ensure|eval|every\?|false\?|ffirst|file-seq|filter|find|find-doc|find-ns|find-var|first|float|flush|fn|fnseq|for|frest|gensym|get|get-proxy-class|hash-map|hash-set|identical\?|identity|if|if-let|if-not|import|in-ns|inc|index|insert-child|insert-left|insert-right|inspect-table|inspect-tree|instance\?|int|interleave|intersection|into|into-array|iterate|join|key|keys|keyword|keyword\?|last|lazy-cat|lazy-cons|left|lefts|let|line-seq|list|list\*|load|load-file|locking|long|loop|macroexpand|macroexpand-1|make-array|make-node|map|map-invert|map\?|mapcat|max|max-key|memfn|merge|merge-with|meta|min|min-key|monitor-enter|name|namespace|neg\?|new|newline|next|nil\?|node|not|not-any\?|not-every\?|not=|ns|ns-imports|ns-interns|ns-map|ns-name|ns-publics|ns-refers|ns-resolve|ns-unmap|nth|nthrest|or|parse|partial|path|peek|pop|pos\?|pr|pr-str|print|print-str|println|println-str|prn|prn-str|project|proxy|proxy-mappings|quot|quote|rand|rand-int|range|re-find|re-groups|re-matcher|re-matches|re-pattern|re-seq|read|read-line|recur|reduce|ref|ref-set|refer|rem|remove|remove-method|remove-ns|rename|rename-keys|repeat|replace|replicate|resolve|rest|resultset-seq|reverse|rfirst|right|rights|root|rrest|rseq|second|select|select-keys|send|send-off|seq|seq-zip|seq\?|set|set!|short|slurp|some|sort|sort-by|sorted-map|sorted-map-by|sorted-set|special-symbol\?|split-at|split-with|str|string\?|struct|struct-map|subs|subvec|symbol|symbol\?|sync|take|take-nth|take-while|test|throw|time|to-array|to-array-2d|tree-seq|true\?|try|union|up|update-proxy|val|vals|var|var-get|var-set|var\?|vector|vector-zip|vector\?|when|when-first|when-let|when-not|with-local-vars|with-meta|with-open|with-out-str|xml-seq|xml-zip|zero\?|zipmap|zipper)(?=[\s)]|$)/,lookbehind:!0},boolean:/\b(?:false|nil|true)\b/,number:{pattern:/(^|[^\w$@])(?:\d+(?:[/.]\d+)?(?:e[+-]?\d+)?|0x[a-f0-9]+|[1-9]\d?r[a-z0-9]+)[lmn]?(?![\w$@])/i,lookbehind:!0},function:{pattern:/((?:^|[^'])\()[\w*+!?'<>=/.-]+(?=[\s)]|$)/,lookbehind:!0},operator:/[#@^`~]/,punctuation:/[{}\[\](),]/}; +!function(e){function n(e,n){return e.replace(/<<(\d+)>>/g,(function(e,s){return"(?:"+n[+s]+")"}))}function s(e,s,a){return RegExp(n(e,s),a||"")}function a(e,n){for(var s=0;s>/g,(function(){return"(?:"+e+")"}));return e.replace(/<>/g,"[^\\s\\S]")}var t="bool byte char decimal double dynamic float int long object sbyte short string uint ulong ushort var void",r="class enum interface record struct",i="add alias and ascending async await by descending from(?=\\s*(?:\\w|$)) get global group into init(?=\\s*;) join let nameof not notnull on or orderby partial remove select set unmanaged value when where with(?=\\s*{)",o="abstract as base break case catch checked const continue default delegate do else event explicit extern finally fixed for foreach goto if implicit in internal is lock namespace new null operator out override params private protected public readonly ref return sealed sizeof stackalloc static switch this throw try typeof unchecked unsafe using virtual volatile while yield";function l(e){return"\\b(?:"+e.trim().replace(/ /g,"|")+")\\b"}var d=l(r),p=RegExp(l(t+" "+r+" "+i+" "+o)),c=l(r+" "+i+" "+o),u=l(t+" "+r+" "+o),g=a("<(?:[^<>;=+\\-*/%&|^]|<>)*>",2),b=a("\\((?:[^()]|<>)*\\)",2),h="@?\\b[A-Za-z_]\\w*\\b",f=n("<<0>>(?:\\s*<<1>>)?",[h,g]),m=n("(?!<<0>>)<<1>>(?:\\s*\\.\\s*<<1>>)*",[c,f]),k="\\[\\s*(?:,\\s*)*\\]",y=n("<<0>>(?:\\s*(?:\\?\\s*)?<<1>>)*(?:\\s*\\?)?",[m,k]),w=n("[^,()<>[\\];=+\\-*/%&|^]|<<0>>|<<1>>|<<2>>",[g,b,k]),v=n("\\(<<0>>+(?:,<<0>>+)+\\)",[w]),x=n("(?:<<0>>|<<1>>)(?:\\s*(?:\\?\\s*)?<<2>>)*(?:\\s*\\?)?",[v,m,k]),$={keyword:p,punctuation:/[<>()?,.:[\]]/},_="'(?:[^\r\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})'",B='"(?:\\\\.|[^\\\\"\r\n])*"';e.languages.csharp=e.languages.extend("clike",{string:[{pattern:s("(^|[^$\\\\])<<0>>",['@"(?:""|\\\\[^]|[^\\\\"])*"(?!")']),lookbehind:!0,greedy:!0},{pattern:s("(^|[^@$\\\\])<<0>>",[B]),lookbehind:!0,greedy:!0}],"class-name":[{pattern:s("(\\busing\\s+static\\s+)<<0>>(?=\\s*;)",[m]),lookbehind:!0,inside:$},{pattern:s("(\\busing\\s+<<0>>\\s*=\\s*)<<1>>(?=\\s*;)",[h,x]),lookbehind:!0,inside:$},{pattern:s("(\\busing\\s+)<<0>>(?=\\s*=)",[h]),lookbehind:!0},{pattern:s("(\\b<<0>>\\s+)<<1>>",[d,f]),lookbehind:!0,inside:$},{pattern:s("(\\bcatch\\s*\\(\\s*)<<0>>",[m]),lookbehind:!0,inside:$},{pattern:s("(\\bwhere\\s+)<<0>>",[h]),lookbehind:!0},{pattern:s("(\\b(?:is(?:\\s+not)?|as)\\s+)<<0>>",[y]),lookbehind:!0,inside:$},{pattern:s("\\b<<0>>(?=\\s+(?!<<1>>|with\\s*\\{)<<2>>(?:\\s*[=,;:{)\\]]|\\s+(?:in|when)\\b))",[x,u,h]),inside:$}],keyword:p,number:/(?:\b0(?:x[\da-f_]*[\da-f]|b[01_]*[01])|(?:\B\.\d+(?:_+\d+)*|\b\d+(?:_+\d+)*(?:\.\d+(?:_+\d+)*)?)(?:e[-+]?\d+(?:_+\d+)*)?)(?:[dflmu]|lu|ul)?\b/i,operator:/>>=?|<<=?|[-=]>|([-+&|])\1|~|\?\?=?|[-+*/%&|^!=<>]=?/,punctuation:/\?\.?|::|[{}[\];(),.:]/}),e.languages.insertBefore("csharp","number",{range:{pattern:/\.\./,alias:"operator"}}),e.languages.insertBefore("csharp","punctuation",{"named-parameter":{pattern:s("([(,]\\s*)<<0>>(?=\\s*:)",[h]),lookbehind:!0,alias:"punctuation"}}),e.languages.insertBefore("csharp","class-name",{namespace:{pattern:s("(\\b(?:namespace|using)\\s+)<<0>>(?:\\s*\\.\\s*<<0>>)*(?=\\s*[;{])",[h]),lookbehind:!0,inside:{punctuation:/\./}},"type-expression":{pattern:s("(\\b(?:default|sizeof|typeof)\\s*\\(\\s*(?!\\s))(?:[^()\\s]|\\s(?!\\s)|<<0>>)*(?=\\s*\\))",[b]),lookbehind:!0,alias:"class-name",inside:$},"return-type":{pattern:s("<<0>>(?=\\s+(?:<<1>>\\s*(?:=>|[({]|\\.\\s*this\\s*\\[)|this\\s*\\[))",[x,m]),inside:$,alias:"class-name"},"constructor-invocation":{pattern:s("(\\bnew\\s+)<<0>>(?=\\s*[[({])",[x]),lookbehind:!0,inside:$,alias:"class-name"},"generic-method":{pattern:s("<<0>>\\s*<<1>>(?=\\s*\\()",[h,g]),inside:{function:s("^<<0>>",[h]),generic:{pattern:RegExp(g),alias:"class-name",inside:$}}},"type-list":{pattern:s("\\b((?:<<0>>\\s+<<1>>|record\\s+<<1>>\\s*<<5>>|where\\s+<<2>>)\\s*:\\s*)(?:<<3>>|<<4>>|<<1>>\\s*<<5>>|<<6>>)(?:\\s*,\\s*(?:<<3>>|<<4>>|<<6>>))*(?=\\s*(?:where|[{;]|=>|$))",[d,f,h,x,p.source,b,"\\bnew\\s*\\(\\s*\\)"]),lookbehind:!0,inside:{"record-arguments":{pattern:s("(^(?!new\\s*\\()<<0>>\\s*)<<1>>",[f,b]),lookbehind:!0,greedy:!0,inside:e.languages.csharp},keyword:p,"class-name":{pattern:RegExp(x),greedy:!0,inside:$},punctuation:/[,()]/}},preprocessor:{pattern:/(^[\t ]*)#.*/m,lookbehind:!0,alias:"property",inside:{directive:{pattern:/(#)\b(?:define|elif|else|endif|endregion|error|if|line|nullable|pragma|region|undef|warning)\b/,lookbehind:!0,alias:"keyword"}}}});var E=B+"|"+_,R=n("/(?![*/])|//[^\r\n]*[\r\n]|/\\*(?:[^*]|\\*(?!/))*\\*/|<<0>>",[E]),z=a(n("[^\"'/()]|<<0>>|\\(<>*\\)",[R]),2),S="\\b(?:assembly|event|field|method|module|param|property|return|type)\\b",j=n("<<0>>(?:\\s*\\(<<1>>*\\))?",[m,z]);e.languages.insertBefore("csharp","class-name",{attribute:{pattern:s("((?:^|[^\\s\\w>)?])\\s*\\[\\s*)(?:<<0>>\\s*:\\s*)?<<1>>(?:\\s*,\\s*<<1>>)*(?=\\s*\\])",[S,j]),lookbehind:!0,greedy:!0,inside:{target:{pattern:s("^<<0>>(?=\\s*:)",[S]),alias:"keyword"},"attribute-arguments":{pattern:s("\\(<<0>>*\\)",[z]),inside:e.languages.csharp},"class-name":{pattern:RegExp(m),inside:{punctuation:/\./}},punctuation:/[:,]/}}});var A=":[^}\r\n]+",F=a(n("[^\"'/()]|<<0>>|\\(<>*\\)",[R]),2),P=n("\\{(?!\\{)(?:(?![}:])<<0>>)*<<1>>?\\}",[F,A]),U=a(n("[^\"'/()]|/(?!\\*)|/\\*(?:[^*]|\\*(?!/))*\\*/|<<0>>|\\(<>*\\)",[E]),2),Z=n("\\{(?!\\{)(?:(?![}:])<<0>>)*<<1>>?\\}",[U,A]);function q(n,a){return{interpolation:{pattern:s("((?:^|[^{])(?:\\{\\{)*)<<0>>",[n]),lookbehind:!0,inside:{"format-string":{pattern:s("(^\\{(?:(?![}:])<<0>>)*)<<1>>(?=\\}$)",[a,A]),lookbehind:!0,inside:{punctuation:/^:/}},punctuation:/^\{|\}$/,expression:{pattern:/[\s\S]+/,alias:"language-csharp",inside:e.languages.csharp}}},string:/[\s\S]+/}}e.languages.insertBefore("csharp","string",{"interpolation-string":[{pattern:s('(^|[^\\\\])(?:\\$@|@\\$)"(?:""|\\\\[^]|\\{\\{|<<0>>|[^\\\\{"])*"',[P]),lookbehind:!0,greedy:!0,inside:q(P,F)},{pattern:s('(^|[^@\\\\])\\$"(?:\\\\.|\\{\\{|<<0>>|[^\\\\"{])*"',[Z]),lookbehind:!0,greedy:!0,inside:q(Z,U)}],char:{pattern:RegExp(_),greedy:!0}}),e.languages.dotnet=e.languages.cs=e.languages.csharp}(Prism); +Prism.languages.fsharp=Prism.languages.extend("clike",{comment:[{pattern:/(^|[^\\])\(\*(?!\))[\s\S]*?\*\)/,lookbehind:!0,greedy:!0},{pattern:/(^|[^\\:])\/\/.*/,lookbehind:!0,greedy:!0}],string:{pattern:/(?:"""[\s\S]*?"""|@"(?:""|[^"])*"|"(?:\\[\s\S]|[^\\"])*")B?/,greedy:!0},"class-name":{pattern:/(\b(?:exception|inherit|interface|new|of|type)\s+|\w\s*:\s*|\s:\??>\s*)[.\w]+\b(?:\s*(?:->|\*)\s*[.\w]+\b)*(?!\s*[:.])/,lookbehind:!0,inside:{operator:/->|\*/,punctuation:/\./}},keyword:/\b(?:let|return|use|yield)(?:!\B|\b)|\b(?:abstract|and|as|asr|assert|atomic|base|begin|break|checked|class|component|const|constraint|constructor|continue|default|delegate|do|done|downcast|downto|eager|elif|else|end|event|exception|extern|external|false|finally|fixed|for|fun|function|functor|global|if|in|include|inherit|inline|interface|internal|land|lazy|lor|lsl|lsr|lxor|match|member|method|mixin|mod|module|mutable|namespace|new|not|null|object|of|open|or|override|parallel|private|process|protected|public|pure|rec|sealed|select|sig|static|struct|tailcall|then|to|trait|true|try|type|upcast|val|virtual|void|volatile|when|while|with)\b/,number:[/\b0x[\da-fA-F]+(?:LF|lf|un)?\b/,/\b0b[01]+(?:uy|y)?\b/,/(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:[fm]|e[+-]?\d+)?\b/i,/\b\d+(?:[IlLsy]|UL|u[lsy]?)?\b/],operator:/([<>~&^])\1\1|([*.:<>&])\2|<-|->|[!=:]=|?|\??(?:<=|>=|<>|[-+*/%=<>])\??|[!?^&]|~[+~-]|:>|:\?>?/}),Prism.languages.insertBefore("fsharp","keyword",{preprocessor:{pattern:/(^[\t ]*)#.*/m,lookbehind:!0,alias:"property",inside:{directive:{pattern:/(^#)\b(?:else|endif|if|light|line|nowarn)\b/,lookbehind:!0,alias:"keyword"}}}}),Prism.languages.insertBefore("fsharp","punctuation",{"computation-expression":{pattern:/\b[_a-z]\w*(?=\s*\{)/i,alias:"keyword"}}),Prism.languages.insertBefore("fsharp","string",{annotation:{pattern:/\[<.+?>\]/,greedy:!0,inside:{punctuation:/^\[<|>\]$/,"class-name":{pattern:/^\w+$|(^|;\s*)[A-Z]\w*(?=\()/,lookbehind:!0},"annotation-content":{pattern:/[\s\S]+/,inside:Prism.languages.fsharp}}},char:{pattern:/'(?:[^\\']|\\(?:.|\d{3}|x[a-fA-F\d]{2}|u[a-fA-F\d]{4}|U[a-fA-F\d]{8}))'B?/,greedy:!0}}); +Prism.languages.python={comment:{pattern:/(^|[^\\])#.*/,lookbehind:!0,greedy:!0},"string-interpolation":{pattern:/(?:f|fr|rf)(?:("""|''')[\s\S]*?\1|("|')(?:\\.|(?!\2)[^\\\r\n])*\2)/i,greedy:!0,inside:{interpolation:{pattern:/((?:^|[^{])(?:\{\{)*)\{(?!\{)(?:[^{}]|\{(?!\{)(?:[^{}]|\{(?!\{)(?:[^{}])+\})+\})+\}/,lookbehind:!0,inside:{"format-spec":{pattern:/(:)[^:(){}]+(?=\}$)/,lookbehind:!0},"conversion-option":{pattern:/![sra](?=[:}]$)/,alias:"punctuation"},rest:null}},string:/[\s\S]+/}},"triple-quoted-string":{pattern:/(?:[rub]|br|rb)?("""|''')[\s\S]*?\1/i,greedy:!0,alias:"string"},string:{pattern:/(?:[rub]|br|rb)?("|')(?:\\.|(?!\1)[^\\\r\n])*\1/i,greedy:!0},function:{pattern:/((?:^|\s)def[ \t]+)[a-zA-Z_]\w*(?=\s*\()/g,lookbehind:!0},"class-name":{pattern:/(\bclass\s+)\w+/i,lookbehind:!0},decorator:{pattern:/(^[\t ]*)@\w+(?:\.\w+)*/m,lookbehind:!0,alias:["annotation","punctuation"],inside:{punctuation:/\./}},keyword:/\b(?:_(?=\s*:)|and|as|assert|async|await|break|case|class|continue|def|del|elif|else|except|exec|finally|for|from|global|if|import|in|is|lambda|match|nonlocal|not|or|pass|print|raise|return|try|while|with|yield)\b/,builtin:/\b(?:__import__|abs|all|any|apply|ascii|basestring|bin|bool|buffer|bytearray|bytes|callable|chr|classmethod|cmp|coerce|compile|complex|delattr|dict|dir|divmod|enumerate|eval|execfile|file|filter|float|format|frozenset|getattr|globals|hasattr|hash|help|hex|id|input|int|intern|isinstance|issubclass|iter|len|list|locals|long|map|max|memoryview|min|next|object|oct|open|ord|pow|property|range|raw_input|reduce|reload|repr|reversed|round|set|setattr|slice|sorted|staticmethod|str|sum|super|tuple|type|unichr|unicode|vars|xrange|zip)\b/,boolean:/\b(?:False|None|True)\b/,number:/\b0(?:b(?:_?[01])+|o(?:_?[0-7])+|x(?:_?[a-f0-9])+)\b|(?:\b\d+(?:_\d+)*(?:\.(?:\d+(?:_\d+)*)?)?|\B\.\d+(?:_\d+)*)(?:e[+-]?\d+(?:_\d+)*)?j?(?!\w)/i,operator:/[-+%=]=?|!=|:=|\*\*?=?|\/\/?=?|<[<=>]?|>[=>]?|[&|^~]/,punctuation:/[{}[\];(),.:]/},Prism.languages.python["string-interpolation"].inside.interpolation.inside.rest=Prism.languages.python,Prism.languages.py=Prism.languages.python; +!function(e){e.languages.ruby=e.languages.extend("clike",{comment:{pattern:/#.*|^=begin\s[\s\S]*?^=end/m,greedy:!0},"class-name":{pattern:/(\b(?:class|module)\s+|\bcatch\s+\()[\w.\\]+|\b[A-Z_]\w*(?=\s*\.\s*new\b)/,lookbehind:!0,inside:{punctuation:/[.\\]/}},keyword:/\b(?:BEGIN|END|alias|and|begin|break|case|class|def|define_method|defined|do|each|else|elsif|end|ensure|extend|for|if|in|include|module|new|next|nil|not|or|prepend|private|protected|public|raise|redo|require|rescue|retry|return|self|super|then|throw|undef|unless|until|when|while|yield)\b/,operator:/\.{2,3}|&\.|===||[!=]?~|(?:&&|\|\||<<|>>|\*\*|[+\-*/%<>!^&|=])=?|[?:]/,punctuation:/[(){}[\].,;]/}),e.languages.insertBefore("ruby","operator",{"double-colon":{pattern:/::/,alias:"punctuation"}});var n={pattern:/((?:^|[^\\])(?:\\{2})*)#\{(?:[^{}]|\{[^{}]*\})*\}/,lookbehind:!0,inside:{content:{pattern:/^(#\{)[\s\S]+(?=\}$)/,lookbehind:!0,inside:e.languages.ruby},delimiter:{pattern:/^#\{|\}$/,alias:"punctuation"}}};delete e.languages.ruby.function;var t="(?:"+["([^a-zA-Z0-9\\s{(\\[<=])(?:(?!\\1)[^\\\\]|\\\\[^])*\\1","\\((?:[^()\\\\]|\\\\[^]|\\((?:[^()\\\\]|\\\\[^])*\\))*\\)","\\{(?:[^{}\\\\]|\\\\[^]|\\{(?:[^{}\\\\]|\\\\[^])*\\})*\\}","\\[(?:[^\\[\\]\\\\]|\\\\[^]|\\[(?:[^\\[\\]\\\\]|\\\\[^])*\\])*\\]","<(?:[^<>\\\\]|\\\\[^]|<(?:[^<>\\\\]|\\\\[^])*>)*>"].join("|")+")",i='(?:"(?:\\\\.|[^"\\\\\r\n])*"|(?:\\b[a-zA-Z_]\\w*|[^\\s\0-\\x7F]+)[?!]?|\\$.)';e.languages.insertBefore("ruby","keyword",{"regex-literal":[{pattern:RegExp("%r"+t+"[egimnosux]{0,6}"),greedy:!0,inside:{interpolation:n,regex:/[\s\S]+/}},{pattern:/(^|[^/])\/(?!\/)(?:\[[^\r\n\]]+\]|\\.|[^[/\\\r\n])+\/[egimnosux]{0,6}(?=\s*(?:$|[\r\n,.;})#]))/,lookbehind:!0,greedy:!0,inside:{interpolation:n,regex:/[\s\S]+/}}],variable:/[@$]+[a-zA-Z_]\w*(?:[?!]|\b)/,symbol:[{pattern:RegExp("(^|[^:]):"+i),lookbehind:!0,greedy:!0},{pattern:RegExp("([\r\n{(,][ \t]*)"+i+"(?=:(?!:))"),lookbehind:!0,greedy:!0}],"method-definition":{pattern:/(\bdef\s+)\w+(?:\s*\.\s*\w+)?/,lookbehind:!0,inside:{function:/\b\w+$/,keyword:/^self\b/,"class-name":/^\w+/,punctuation:/\./}}}),e.languages.insertBefore("ruby","string",{"string-literal":[{pattern:RegExp("%[qQiIwWs]?"+t),greedy:!0,inside:{interpolation:n,string:/[\s\S]+/}},{pattern:/("|')(?:#\{[^}]+\}|#(?!\{)|\\(?:\r\n|[\s\S])|(?!\1)[^\\#\r\n])*\1/,greedy:!0,inside:{interpolation:n,string:/[\s\S]+/}},{pattern:/<<[-~]?([a-z_]\w*)[\r\n](?:.*[\r\n])*?[\t ]*\1/i,alias:"heredoc-string",greedy:!0,inside:{delimiter:{pattern:/^<<[-~]?[a-z_]\w*|\b[a-z_]\w*$/i,inside:{symbol:/\b\w+/,punctuation:/^<<[-~]?/}},interpolation:n,string:/[\s\S]+/}},{pattern:/<<[-~]?'([a-z_]\w*)'[\r\n](?:.*[\r\n])*?[\t ]*\1/i,alias:"heredoc-string",greedy:!0,inside:{delimiter:{pattern:/^<<[-~]?'[a-z_]\w*'|\b[a-z_]\w*$/i,inside:{symbol:/\b\w+/,punctuation:/^<<[-~]?'|'$/}},string:/[\s\S]+/}}],"command-literal":[{pattern:RegExp("%x"+t),greedy:!0,inside:{interpolation:n,command:{pattern:/[\s\S]+/,alias:"string"}}},{pattern:/`(?:#\{[^}]+\}|#(?!\{)|\\(?:\r\n|[\s\S])|[^\\`#\r\n])*`/,greedy:!0,inside:{interpolation:n,command:{pattern:/[\s\S]+/,alias:"string"}}}]}),delete e.languages.ruby.string,e.languages.insertBefore("ruby","number",{builtin:/\b(?:Array|Bignum|Binding|Class|Continuation|Dir|Exception|FalseClass|File|Fixnum|Float|Hash|IO|Integer|MatchData|Method|Module|NilClass|Numeric|Object|Proc|Range|Regexp|Stat|String|Struct|Symbol|TMS|Thread|ThreadGroup|Time|TrueClass)\b/,constant:/\b[A-Z][A-Z0-9_]*(?:[?!]|\b)/}),e.languages.rb=e.languages.ruby}(Prism); +!function(e){var a=/\/\*[\s\S]*?\*\/|\/\/.*|#(?!\[).*/,t=[{pattern:/\b(?:false|true)\b/i,alias:"boolean"},{pattern:/(::\s*)\b[a-z_]\w*\b(?!\s*\()/i,greedy:!0,lookbehind:!0},{pattern:/(\b(?:case|const)\s+)\b[a-z_]\w*(?=\s*[;=])/i,greedy:!0,lookbehind:!0},/\b(?:null)\b/i,/\b[A-Z_][A-Z0-9_]*\b(?!\s*\()/],i=/\b0b[01]+(?:_[01]+)*\b|\b0o[0-7]+(?:_[0-7]+)*\b|\b0x[\da-f]+(?:_[\da-f]+)*\b|(?:\b\d+(?:_\d+)*\.?(?:\d+(?:_\d+)*)?|\B\.\d+)(?:e[+-]?\d+)?/i,n=/|\?\?=?|\.{3}|\??->|[!=]=?=?|::|\*\*=?|--|\+\+|&&|\|\||<<|>>|[?~]|[/^|%*&<>.+-]=?/,s=/[{}\[\](),:;]/;e.languages.php={delimiter:{pattern:/\?>$|^<\?(?:php(?=\s)|=)?/i,alias:"important"},comment:a,variable:/\$+(?:\w+\b|(?=\{))/,package:{pattern:/(namespace\s+|use\s+(?:function\s+)?)(?:\\?\b[a-z_]\w*)+\b(?!\\)/i,lookbehind:!0,inside:{punctuation:/\\/}},"class-name-definition":{pattern:/(\b(?:class|enum|interface|trait)\s+)\b[a-z_]\w*(?!\\)\b/i,lookbehind:!0,alias:"class-name"},"function-definition":{pattern:/(\bfunction\s+)[a-z_]\w*(?=\s*\()/i,lookbehind:!0,alias:"function"},keyword:[{pattern:/(\(\s*)\b(?:array|bool|boolean|float|int|integer|object|string)\b(?=\s*\))/i,alias:"type-casting",greedy:!0,lookbehind:!0},{pattern:/([(,?]\s*)\b(?:array(?!\s*\()|bool|callable|(?:false|null)(?=\s*\|)|float|int|iterable|mixed|object|self|static|string)\b(?=\s*\$)/i,alias:"type-hint",greedy:!0,lookbehind:!0},{pattern:/(\)\s*:\s*(?:\?\s*)?)\b(?:array(?!\s*\()|bool|callable|(?:false|null)(?=\s*\|)|float|int|iterable|mixed|never|object|self|static|string|void)\b/i,alias:"return-type",greedy:!0,lookbehind:!0},{pattern:/\b(?:array(?!\s*\()|bool|float|int|iterable|mixed|object|string|void)\b/i,alias:"type-declaration",greedy:!0},{pattern:/(\|\s*)(?:false|null)\b|\b(?:false|null)(?=\s*\|)/i,alias:"type-declaration",greedy:!0,lookbehind:!0},{pattern:/\b(?:parent|self|static)(?=\s*::)/i,alias:"static-context",greedy:!0},{pattern:/(\byield\s+)from\b/i,lookbehind:!0},/\bclass\b/i,{pattern:/((?:^|[^\s>:]|(?:^|[^-])>|(?:^|[^:]):)\s*)\b(?:abstract|and|array|as|break|callable|case|catch|clone|const|continue|declare|default|die|do|echo|else|elseif|empty|enddeclare|endfor|endforeach|endif|endswitch|endwhile|enum|eval|exit|extends|final|finally|fn|for|foreach|function|global|goto|if|implements|include|include_once|instanceof|insteadof|interface|isset|list|match|namespace|never|new|or|parent|print|private|protected|public|readonly|require|require_once|return|self|static|switch|throw|trait|try|unset|use|var|while|xor|yield|__halt_compiler)\b/i,lookbehind:!0}],"argument-name":{pattern:/([(,]\s*)\b[a-z_]\w*(?=\s*:(?!:))/i,lookbehind:!0},"class-name":[{pattern:/(\b(?:extends|implements|instanceof|new(?!\s+self|\s+static))\s+|\bcatch\s*\()\b[a-z_]\w*(?!\\)\b/i,greedy:!0,lookbehind:!0},{pattern:/(\|\s*)\b[a-z_]\w*(?!\\)\b/i,greedy:!0,lookbehind:!0},{pattern:/\b[a-z_]\w*(?!\\)\b(?=\s*\|)/i,greedy:!0},{pattern:/(\|\s*)(?:\\?\b[a-z_]\w*)+\b/i,alias:"class-name-fully-qualified",greedy:!0,lookbehind:!0,inside:{punctuation:/\\/}},{pattern:/(?:\\?\b[a-z_]\w*)+\b(?=\s*\|)/i,alias:"class-name-fully-qualified",greedy:!0,inside:{punctuation:/\\/}},{pattern:/(\b(?:extends|implements|instanceof|new(?!\s+self\b|\s+static\b))\s+|\bcatch\s*\()(?:\\?\b[a-z_]\w*)+\b(?!\\)/i,alias:"class-name-fully-qualified",greedy:!0,lookbehind:!0,inside:{punctuation:/\\/}},{pattern:/\b[a-z_]\w*(?=\s*\$)/i,alias:"type-declaration",greedy:!0},{pattern:/(?:\\?\b[a-z_]\w*)+(?=\s*\$)/i,alias:["class-name-fully-qualified","type-declaration"],greedy:!0,inside:{punctuation:/\\/}},{pattern:/\b[a-z_]\w*(?=\s*::)/i,alias:"static-context",greedy:!0},{pattern:/(?:\\?\b[a-z_]\w*)+(?=\s*::)/i,alias:["class-name-fully-qualified","static-context"],greedy:!0,inside:{punctuation:/\\/}},{pattern:/([(,?]\s*)[a-z_]\w*(?=\s*\$)/i,alias:"type-hint",greedy:!0,lookbehind:!0},{pattern:/([(,?]\s*)(?:\\?\b[a-z_]\w*)+(?=\s*\$)/i,alias:["class-name-fully-qualified","type-hint"],greedy:!0,lookbehind:!0,inside:{punctuation:/\\/}},{pattern:/(\)\s*:\s*(?:\?\s*)?)\b[a-z_]\w*(?!\\)\b/i,alias:"return-type",greedy:!0,lookbehind:!0},{pattern:/(\)\s*:\s*(?:\?\s*)?)(?:\\?\b[a-z_]\w*)+\b(?!\\)/i,alias:["class-name-fully-qualified","return-type"],greedy:!0,lookbehind:!0,inside:{punctuation:/\\/}}],constant:t,function:{pattern:/(^|[^\\\w])\\?[a-z_](?:[\w\\]*\w)?(?=\s*\()/i,lookbehind:!0,inside:{punctuation:/\\/}},property:{pattern:/(->\s*)\w+/,lookbehind:!0},number:i,operator:n,punctuation:s};var l={pattern:/\{\$(?:\{(?:\{[^{}]+\}|[^{}]+)\}|[^{}])+\}|(^|[^\\{])\$+(?:\w+(?:\[[^\r\n\[\]]+\]|->\w+)?)/,lookbehind:!0,inside:e.languages.php},r=[{pattern:/<<<'([^']+)'[\r\n](?:.*[\r\n])*?\1;/,alias:"nowdoc-string",greedy:!0,inside:{delimiter:{pattern:/^<<<'[^']+'|[a-z_]\w*;$/i,alias:"symbol",inside:{punctuation:/^<<<'?|[';]$/}}}},{pattern:/<<<(?:"([^"]+)"[\r\n](?:.*[\r\n])*?\1;|([a-z_]\w*)[\r\n](?:.*[\r\n])*?\2;)/i,alias:"heredoc-string",greedy:!0,inside:{delimiter:{pattern:/^<<<(?:"[^"]+"|[a-z_]\w*)|[a-z_]\w*;$/i,alias:"symbol",inside:{punctuation:/^<<<"?|[";]$/}},interpolation:l}},{pattern:/`(?:\\[\s\S]|[^\\`])*`/,alias:"backtick-quoted-string",greedy:!0},{pattern:/'(?:\\[\s\S]|[^\\'])*'/,alias:"single-quoted-string",greedy:!0},{pattern:/"(?:\\[\s\S]|[^\\"])*"/,alias:"double-quoted-string",greedy:!0,inside:{interpolation:l}}];e.languages.insertBefore("php","variable",{string:r,attribute:{pattern:/#\[(?:[^"'\/#]|\/(?![*/])|\/\/.*$|#(?!\[).*$|\/\*(?:[^*]|\*(?!\/))*\*\/|"(?:\\[\s\S]|[^\\"])*"|'(?:\\[\s\S]|[^\\'])*')+\](?=\s*[a-z$#])/im,greedy:!0,inside:{"attribute-content":{pattern:/^(#\[)[\s\S]+(?=\]$)/,lookbehind:!0,inside:{comment:a,string:r,"attribute-class-name":[{pattern:/([^:]|^)\b[a-z_]\w*(?!\\)\b/i,alias:"class-name",greedy:!0,lookbehind:!0},{pattern:/([^:]|^)(?:\\?\b[a-z_]\w*)+/i,alias:["class-name","class-name-fully-qualified"],greedy:!0,lookbehind:!0,inside:{punctuation:/\\/}}],constant:t,number:i,operator:n,punctuation:s}},delimiter:{pattern:/^#\[|\]$/,alias:"punctuation"}}}}),e.hooks.add("before-tokenize",(function(a){/<\?/.test(a.code)&&e.languages["markup-templating"].buildPlaceholders(a,"php",/<\?(?:[^"'/#]|\/(?![*/])|("|')(?:\\[\s\S]|(?!\1)[^\\])*\1|(?:\/\/|#(?!\[))(?:[^?\n\r]|\?(?!>))*(?=$|\?>|[\r\n])|#\[|\/\*(?:[^*]|\*(?!\/))*(?:\*\/|$))*?(?:\?>|$)/g)})),e.hooks.add("after-tokenize",(function(a){e.languages["markup-templating"].tokenizePlaceholders(a,"php")}))}(Prism); +!function(e){var n="(?:\\((?:[^()\\\\]|\\\\[^])*\\)|\\{(?:[^{}\\\\]|\\\\[^])*\\}|\\[(?:[^[\\]\\\\]|\\\\[^])*\\]|<(?:[^<>\\\\]|\\\\[^])*>)";e.languages.perl={comment:[{pattern:/(^\s*)=\w[\s\S]*?=cut.*/m,lookbehind:!0,greedy:!0},{pattern:/(^|[^\\$])#.*/,lookbehind:!0,greedy:!0}],string:[{pattern:RegExp("\\b(?:q|qq|qw|qx)(?![a-zA-Z0-9])\\s*(?:"+["([^a-zA-Z0-9\\s{(\\[<])(?:(?!\\1)[^\\\\]|\\\\[^])*\\1","([a-zA-Z0-9])(?:(?!\\2)[^\\\\]|\\\\[^])*\\2",n].join("|")+")"),greedy:!0},{pattern:/("|`)(?:(?!\1)[^\\]|\\[\s\S])*\1/,greedy:!0},{pattern:/'(?:[^'\\\r\n]|\\.)*'/,greedy:!0}],regex:[{pattern:RegExp("\\b(?:m|qr)(?![a-zA-Z0-9])\\s*(?:"+["([^a-zA-Z0-9\\s{(\\[<])(?:(?!\\1)[^\\\\]|\\\\[^])*\\1","([a-zA-Z0-9])(?:(?!\\2)[^\\\\]|\\\\[^])*\\2",n].join("|")+")[msixpodualngc]*"),greedy:!0},{pattern:RegExp("(^|[^-])\\b(?:s|tr|y)(?![a-zA-Z0-9])\\s*(?:"+["([^a-zA-Z0-9\\s{(\\[<])(?:(?!\\2)[^\\\\]|\\\\[^])*\\2(?:(?!\\2)[^\\\\]|\\\\[^])*\\2","([a-zA-Z0-9])(?:(?!\\3)[^\\\\]|\\\\[^])*\\3(?:(?!\\3)[^\\\\]|\\\\[^])*\\3",n+"\\s*"+n].join("|")+")[msixpodualngcer]*"),lookbehind:!0,greedy:!0},{pattern:/\/(?:[^\/\\\r\n]|\\.)*\/[msixpodualngc]*(?=\s*(?:$|[\r\n,.;})&|\-+*~<>!?^]|(?:and|cmp|eq|ge|gt|le|lt|ne|not|or|x|xor)\b))/,greedy:!0}],variable:[/[&*$@%]\{\^[A-Z]+\}/,/[&*$@%]\^[A-Z_]/,/[&*$@%]#?(?=\{)/,/[&*$@%]#?(?:(?:::)*'?(?!\d)[\w$]+(?![\w$]))+(?:::)*/,/[&*$@%]\d+/,/(?!%=)[$@%][!"#$%&'()*+,\-.\/:;<=>?@[\\\]^_`{|}~]/],filehandle:{pattern:/<(?![<=])\S*?>|\b_\b/,alias:"symbol"},"v-string":{pattern:/v\d+(?:\.\d+)*|\d+(?:\.\d+){2,}/,alias:"string"},function:{pattern:/(\bsub[ \t]+)\w+/,lookbehind:!0},keyword:/\b(?:any|break|continue|default|delete|die|do|else|elsif|eval|for|foreach|given|goto|if|last|local|my|next|our|package|print|redo|require|return|say|state|sub|switch|undef|unless|until|use|when|while)\b/,number:/\b(?:0x[\dA-Fa-f](?:_?[\dA-Fa-f])*|0b[01](?:_?[01])*|(?:(?:\d(?:_?\d)*)?\.)?\d(?:_?\d)*(?:[Ee][+-]?\d+)?)\b/,operator:/-[rwxoRWXOezsfdlpSbctugkTBMAC]\b|\+[+=]?|-[-=>]?|\*\*?=?|\/\/?=?|=[=~>]?|~[~=]?|\|\|?=?|&&?=?|<(?:=>?|<=?)?|>>?=?|![~=]?|[%^]=?|\.(?:=|\.\.?)?|[\\?]|\bx(?:=|\b)|\b(?:and|cmp|eq|ge|gt|le|lt|ne|not|or|xor)\b/,punctuation:/[{}[\];(),:]/}}(Prism); +Prism.languages.lua={comment:/^#!.+|--(?:\[(=*)\[[\s\S]*?\]\1\]|.*)/m,string:{pattern:/(["'])(?:(?!\1)[^\\\r\n]|\\z(?:\r\n|\s)|\\(?:\r\n|[^z]))*\1|\[(=*)\[[\s\S]*?\]\2\]/,greedy:!0},number:/\b0x[a-f\d]+(?:\.[a-f\d]*)?(?:p[+-]?\d+)?\b|\b\d+(?:\.\B|(?:\.\d*)?(?:e[+-]?\d+)?\b)|\B\.\d+(?:e[+-]?\d+)?\b/i,keyword:/\b(?:and|break|do|else|elseif|end|false|for|function|goto|if|in|local|nil|not|or|repeat|return|then|true|until|while)\b/,function:/(?!\d)\w+(?=\s*(?:[({]))/,operator:[/[-+*%^&|#]|\/\/?|<[<=]?|>[>=]?|[=~]=?/,{pattern:/(^|[^.])\.\.(?!\.)/,lookbehind:!0}],punctuation:/[\[\](){},;]|\.+|:+/}; +Prism.languages.elixir={doc:{pattern:/@(?:doc|moduledoc)\s+(?:("""|''')[\s\S]*?\1|("|')(?:\\(?:\r\n|[\s\S])|(?!\2)[^\\\r\n])*\2)/,inside:{attribute:/^@\w+/,string:/['"][\s\S]+/}},comment:{pattern:/#.*/,greedy:!0},regex:{pattern:/~[rR](?:("""|''')(?:\\[\s\S]|(?!\1)[^\\])+\1|([\/|"'])(?:\\.|(?!\2)[^\\\r\n])+\2|\((?:\\.|[^\\)\r\n])+\)|\[(?:\\.|[^\\\]\r\n])+\]|\{(?:\\.|[^\\}\r\n])+\}|<(?:\\.|[^\\>\r\n])+>)[uismxfr]*/,greedy:!0},string:[{pattern:/~[cCsSwW](?:("""|''')(?:\\[\s\S]|(?!\1)[^\\])+\1|([\/|"'])(?:\\.|(?!\2)[^\\\r\n])+\2|\((?:\\.|[^\\)\r\n])+\)|\[(?:\\.|[^\\\]\r\n])+\]|\{(?:\\.|#\{[^}]+\}|#(?!\{)|[^#\\}\r\n])+\}|<(?:\\.|[^\\>\r\n])+>)[csa]?/,greedy:!0,inside:{}},{pattern:/("""|''')[\s\S]*?\1/,greedy:!0,inside:{}},{pattern:/("|')(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/,greedy:!0,inside:{}}],atom:{pattern:/(^|[^:]):\w+/,lookbehind:!0,alias:"symbol"},module:{pattern:/\b[A-Z]\w*\b/,alias:"class-name"},"attr-name":/\b\w+\??:(?!:)/,argument:{pattern:/(^|[^&])&\d+/,lookbehind:!0,alias:"variable"},attribute:{pattern:/@\w+/,alias:"variable"},function:/\b[_a-zA-Z]\w*[?!]?(?:(?=\s*(?:\.\s*)?\()|(?=\/\d))/,number:/\b(?:0[box][a-f\d_]+|\d[\d_]*)(?:\.[\d_]+)?(?:e[+-]?[\d_]+)?\b/i,keyword:/\b(?:after|alias|and|case|catch|cond|def(?:callback|delegate|exception|impl|macro|module|n|np|p|protocol|struct)?|do|else|end|fn|for|if|import|not|or|quote|raise|require|rescue|try|unless|unquote|use|when)\b/,boolean:/\b(?:false|nil|true)\b/,operator:[/\bin\b|&&?|\|[|>]?|\\\\|::|\.\.\.?|\+\+?|-[->]?|<[-=>]|>=|!==?|\B!|=(?:==?|[>~])?|[*\/^]/,{pattern:/([^<])<(?!<)/,lookbehind:!0},{pattern:/([^>])>(?!>)/,lookbehind:!0}],punctuation:/<<|>>|[.,%\[\]{}()]/},Prism.languages.elixir.string.forEach((function(e){e.inside={interpolation:{pattern:/#\{[^}]+\}/,inside:{delimiter:{pattern:/^#\{|\}$/,alias:"punctuation"},rest:Prism.languages.elixir}}}})); +Prism.languages.erlang={comment:/%.+/,string:{pattern:/"(?:\\.|[^\\"\r\n])*"/,greedy:!0},"quoted-function":{pattern:/'(?:\\.|[^\\'\r\n])+'(?=\()/,alias:"function"},"quoted-atom":{pattern:/'(?:\\.|[^\\'\r\n])+'/,alias:"atom"},boolean:/\b(?:false|true)\b/,keyword:/\b(?:after|begin|case|catch|end|fun|if|of|receive|try|when)\b/,number:[/\$\\?./,/\b\d+#[a-z0-9]+/i,/(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:e[+-]?\d+)?/i],function:/\b[a-z][\w@]*(?=\()/,variable:{pattern:/(^|[^@])(?:\b|\?)[A-Z_][\w@]*/,lookbehind:!0},operator:[/[=\/<>:]=|=[:\/]=|\+\+?|--?|[=*\/!]|\b(?:and|andalso|band|bnot|bor|bsl|bsr|bxor|div|not|or|orelse|rem|xor)\b/,{pattern:/(^|[^<])<(?!<)/,lookbehind:!0},{pattern:/(^|[^>])>(?!>)/,lookbehind:!0}],atom:/\b[a-z][\w@]*/,punctuation:/[()[\]{}:;,.#|]|<<|>>/}; +Prism.languages.haskell={comment:{pattern:/(^|[^-!#$%*+=?&@|~.:<>^\\\/])(?:--(?:(?=.)[^-!#$%*+=?&@|~.:<>^\\\/].*|$)|\{-[\s\S]*?-\})/m,lookbehind:!0},char:{pattern:/'(?:[^\\']|\\(?:[abfnrtv\\"'&]|\^[A-Z@[\]^_]|ACK|BEL|BS|CAN|CR|DC1|DC2|DC3|DC4|DEL|DLE|EM|ENQ|EOT|ESC|ETB|ETX|FF|FS|GS|HT|LF|NAK|NUL|RS|SI|SO|SOH|SP|STX|SUB|SYN|US|VT|\d+|o[0-7]+|x[0-9a-fA-F]+))'/,alias:"string"},string:{pattern:/"(?:[^\\"]|\\(?:\S|\s+\\))*"/,greedy:!0},keyword:/\b(?:case|class|data|deriving|do|else|if|in|infixl|infixr|instance|let|module|newtype|of|primitive|then|type|where)\b/,"import-statement":{pattern:/(^[\t ]*)import\s+(?:qualified\s+)?(?:[A-Z][\w']*)(?:\.[A-Z][\w']*)*(?:\s+as\s+(?:[A-Z][\w']*)(?:\.[A-Z][\w']*)*)?(?:\s+hiding\b)?/m,lookbehind:!0,inside:{keyword:/\b(?:as|hiding|import|qualified)\b/,punctuation:/\./}},builtin:/\b(?:abs|acos|acosh|all|and|any|appendFile|approxRational|asTypeOf|asin|asinh|atan|atan2|atanh|basicIORun|break|catch|ceiling|chr|compare|concat|concatMap|const|cos|cosh|curry|cycle|decodeFloat|denominator|digitToInt|div|divMod|drop|dropWhile|either|elem|encodeFloat|enumFrom|enumFromThen|enumFromThenTo|enumFromTo|error|even|exp|exponent|fail|filter|flip|floatDigits|floatRadix|floatRange|floor|fmap|foldl|foldl1|foldr|foldr1|fromDouble|fromEnum|fromInt|fromInteger|fromIntegral|fromRational|fst|gcd|getChar|getContents|getLine|group|head|id|inRange|index|init|intToDigit|interact|ioError|isAlpha|isAlphaNum|isAscii|isControl|isDenormalized|isDigit|isHexDigit|isIEEE|isInfinite|isLower|isNaN|isNegativeZero|isOctDigit|isPrint|isSpace|isUpper|iterate|last|lcm|length|lex|lexDigits|lexLitChar|lines|log|logBase|lookup|map|mapM|mapM_|max|maxBound|maximum|maybe|min|minBound|minimum|mod|negate|not|notElem|null|numerator|odd|or|ord|otherwise|pack|pi|pred|primExitWith|print|product|properFraction|putChar|putStr|putStrLn|quot|quotRem|range|rangeSize|read|readDec|readFile|readFloat|readHex|readIO|readInt|readList|readLitChar|readLn|readOct|readParen|readSigned|reads|readsPrec|realToFrac|recip|rem|repeat|replicate|return|reverse|round|scaleFloat|scanl|scanl1|scanr|scanr1|seq|sequence|sequence_|show|showChar|showInt|showList|showLitChar|showParen|showSigned|showString|shows|showsPrec|significand|signum|sin|sinh|snd|sort|span|splitAt|sqrt|subtract|succ|sum|tail|take|takeWhile|tan|tanh|threadToIOResult|toEnum|toInt|toInteger|toLower|toRational|toUpper|truncate|uncurry|undefined|unlines|until|unwords|unzip|unzip3|userError|words|writeFile|zip|zip3|zipWith|zipWith3)\b/,number:/\b(?:\d+(?:\.\d+)?(?:e[+-]?\d+)?|0o[0-7]+|0x[0-9a-f]+)\b/i,operator:[{pattern:/`(?:[A-Z][\w']*\.)*[_a-z][\w']*`/,greedy:!0},{pattern:/(\s)\.(?=\s)/,lookbehind:!0},/[-!#$%*+=?&@|~:<>^\\\/][-!#$%*+=?&@|~.:<>^\\\/]*|\.[-!#$%*+=?&@|~.:<>^\\\/]+/],hvariable:{pattern:/\b(?:[A-Z][\w']*\.)*[_a-z][\w']*/,inside:{punctuation:/\./}},constant:{pattern:/\b(?:[A-Z][\w']*\.)*[A-Z][\w']*/,inside:{punctuation:/\./}},punctuation:/[{}[\];(),.:]/},Prism.languages.hs=Prism.languages.haskell; +Prism.languages.ocaml={comment:{pattern:/\(\*[\s\S]*?\*\)/,greedy:!0},char:{pattern:/'(?:[^\\\r\n']|\\(?:.|[ox]?[0-9a-f]{1,3}))'/i,greedy:!0},string:[{pattern:/"(?:\\(?:[\s\S]|\r\n)|[^\\\r\n"])*"/,greedy:!0},{pattern:/\{([a-z_]*)\|[\s\S]*?\|\1\}/,greedy:!0}],number:[/\b(?:0b[01][01_]*|0o[0-7][0-7_]*)\b/i,/\b0x[a-f0-9][a-f0-9_]*(?:\.[a-f0-9_]*)?(?:p[+-]?\d[\d_]*)?(?!\w)/i,/\b\d[\d_]*(?:\.[\d_]*)?(?:e[+-]?\d[\d_]*)?(?!\w)/i],directive:{pattern:/\B#\w+/,alias:"property"},label:{pattern:/\B~\w+/,alias:"property"},"type-variable":{pattern:/\B'\w+/,alias:"function"},variant:{pattern:/`\w+/,alias:"symbol"},keyword:/\b(?:as|assert|begin|class|constraint|do|done|downto|else|end|exception|external|for|fun|function|functor|if|in|include|inherit|initializer|lazy|let|match|method|module|mutable|new|nonrec|object|of|open|private|rec|sig|struct|then|to|try|type|val|value|virtual|when|where|while|with)\b/,boolean:/\b(?:false|true)\b/,"operator-like-punctuation":{pattern:/\[[<>|]|[>|]\]|\{<|>\}/,alias:"punctuation"},operator:/\.[.~]|:[=>]|[=<>@^|&+\-*\/$%!?~][!$%&*+\-.\/:<=>?@^|~]*|\b(?:and|asr|land|lor|lsl|lsr|lxor|mod|or)\b/,punctuation:/;;|::|[(){}\[\].,:;#]|\b_\b/}; +Prism.languages.reason=Prism.languages.extend("clike",{string:{pattern:/"(?:\\(?:\r\n|[\s\S])|[^\\\r\n"])*"/,greedy:!0},"class-name":/\b[A-Z]\w*/,keyword:/\b(?:and|as|assert|begin|class|constraint|do|done|downto|else|end|exception|external|for|fun|function|functor|if|in|include|inherit|initializer|lazy|let|method|module|mutable|new|nonrec|object|of|open|or|private|rec|sig|struct|switch|then|to|try|type|val|virtual|when|while|with)\b/,operator:/\.{3}|:[:=]|\|>|->|=(?:==?|>)?|<=?|>=?|[|^?'#!~`]|[+\-*\/]\.?|\b(?:asr|land|lor|lsl|lsr|lxor|mod)\b/}),Prism.languages.insertBefore("reason","class-name",{char:{pattern:/'(?:\\x[\da-f]{2}|\\o[0-3][0-7][0-7]|\\\d{3}|\\.|[^'\\\r\n])'/,greedy:!0},constructor:/\b[A-Z]\w*\b(?!\s*\.)/,label:{pattern:/\b[a-z]\w*(?=::)/,alias:"symbol"}}),delete Prism.languages.reason.function; +!function(e){function n(e){return RegExp("(\\()(?:"+e+")(?=[\\s\\)])")}function a(e){return RegExp("([\\s([])(?:"+e+")(?=[\\s)])")}var t="(?!\\d)[-+*/~!@$%^=<>{}\\w]+",r="(\\()",i="(?:[^()]|\\((?:[^()]|\\((?:[^()]|\\((?:[^()]|\\((?:[^()]|\\([^()]*\\))*\\))*\\))*\\))*\\))*",s={heading:{pattern:/;;;.*/,alias:["comment","title"]},comment:/;.*/,string:{pattern:/"(?:[^"\\]|\\.)*"/,greedy:!0,inside:{argument:/[-A-Z]+(?=[.,\s])/,symbol:RegExp("`"+t+"'")}},"quoted-symbol":{pattern:RegExp("#?'"+t),alias:["variable","symbol"]},"lisp-property":{pattern:RegExp(":"+t),alias:"property"},splice:{pattern:RegExp(",@?"+t),alias:["symbol","variable"]},keyword:[{pattern:RegExp("(\\()(?:and|(?:cl-)?letf|cl-loop|cond|cons|error|if|(?:lexical-)?let\\*?|message|not|null|or|provide|require|setq|unless|use-package|when|while)(?=\\s)"),lookbehind:!0},{pattern:RegExp("(\\()(?:append|by|collect|concat|do|finally|for|in|return)(?=\\s)"),lookbehind:!0}],declare:{pattern:n("declare"),lookbehind:!0,alias:"keyword"},interactive:{pattern:n("interactive"),lookbehind:!0,alias:"keyword"},boolean:{pattern:a("nil|t"),lookbehind:!0},number:{pattern:a("[-+]?\\d+(?:\\.\\d*)?"),lookbehind:!0},defvar:{pattern:RegExp("(\\()def(?:const|custom|group|var)\\s+"+t),lookbehind:!0,inside:{keyword:/^def[a-z]+/,variable:RegExp(t)}},defun:{pattern:RegExp("(\\()(?:cl-)?(?:defmacro|defun\\*?)\\s+"+t+"\\s+\\("+i+"\\)"),lookbehind:!0,greedy:!0,inside:{keyword:/^(?:cl-)?def\S+/,arguments:null,function:{pattern:RegExp("(^\\s)"+t),lookbehind:!0},punctuation:/[()]/}},lambda:{pattern:RegExp("(\\()lambda\\s+\\(\\s*(?:&?"+t+"(?:\\s+&?"+t+")*\\s*)?\\)"),lookbehind:!0,greedy:!0,inside:{keyword:/^lambda/,arguments:null,punctuation:/[()]/}},car:{pattern:RegExp(r+t),lookbehind:!0},punctuation:[/(?:['`,]?\(|[)\[\]])/,{pattern:/(\s)\.(?=\s)/,lookbehind:!0}]},l={"lisp-marker":RegExp("&(?!\\d)[-+*/~!@$%^=<>{}\\w]+"),varform:{pattern:RegExp("\\("+t+"\\s+(?=\\S)"+i+"\\)"),inside:s},argument:{pattern:RegExp("(^|[\\s(])"+t),lookbehind:!0,alias:"variable"},rest:s},o="\\S+(?:\\s+\\S+)*",p={pattern:RegExp(r+i+"(?=\\))"),lookbehind:!0,inside:{"rest-vars":{pattern:RegExp("&(?:body|rest)\\s+"+o),inside:l},"other-marker-vars":{pattern:RegExp("&(?:aux|optional)\\s+"+o),inside:l},keys:{pattern:RegExp("&key\\s+"+o+"(?:\\s+&allow-other-keys)?"),inside:l},argument:{pattern:RegExp(t),alias:"variable"},punctuation:/[()]/}};s.lambda.inside.arguments=p,s.defun.inside.arguments=e.util.clone(p),s.defun.inside.arguments.inside.sublist=p,e.languages.lisp=s,e.languages.elisp=s,e.languages.emacs=s,e.languages["emacs-lisp"]=s}(Prism); +!function(e){e.languages.scheme={comment:/;.*|#;\s*(?:\((?:[^()]|\([^()]*\))*\)|\[(?:[^\[\]]|\[[^\[\]]*\])*\])|#\|(?:[^#|]|#(?!\|)|\|(?!#)|#\|(?:[^#|]|#(?!\|)|\|(?!#))*\|#)*\|#/,string:{pattern:/"(?:[^"\\]|\\.)*"/,greedy:!0},symbol:{pattern:/'[^()\[\]#'\s]+/,greedy:!0},char:{pattern:/#\\(?:[ux][a-fA-F\d]+\b|[-a-zA-Z]+\b|[\uD800-\uDBFF][\uDC00-\uDFFF]|\S)/,greedy:!0},"lambda-parameter":[{pattern:/((?:^|[^'`#])[(\[]lambda\s+)(?:[^|()\[\]'\s]+|\|(?:[^\\|]|\\.)*\|)/,lookbehind:!0},{pattern:/((?:^|[^'`#])[(\[]lambda\s+[(\[])[^()\[\]']+/,lookbehind:!0}],keyword:{pattern:/((?:^|[^'`#])[(\[])(?:begin|case(?:-lambda)?|cond(?:-expand)?|define(?:-library|-macro|-record-type|-syntax|-values)?|defmacro|delay(?:-force)?|do|else|except|export|guard|if|import|include(?:-ci|-library-declarations)?|lambda|let(?:rec)?(?:-syntax|-values|\*)?|let\*-values|only|parameterize|prefix|(?:quasi-?)?quote|rename|set!|syntax-(?:case|rules)|unless|unquote(?:-splicing)?|when)(?=[()\[\]\s]|$)/,lookbehind:!0},builtin:{pattern:/((?:^|[^'`#])[(\[])(?:abs|and|append|apply|assoc|ass[qv]|binary-port\?|boolean=?\?|bytevector(?:-append|-copy|-copy!|-length|-u8-ref|-u8-set!|\?)?|caar|cadr|call-with-(?:current-continuation|port|values)|call\/cc|car|cdar|cddr|cdr|ceiling|char(?:->integer|-ready\?|\?|<\?|<=\?|=\?|>\?|>=\?)|close-(?:input-port|output-port|port)|complex\?|cons|current-(?:error|input|output)-port|denominator|dynamic-wind|eof-object\??|eq\?|equal\?|eqv\?|error|error-object(?:-irritants|-message|\?)|eval|even\?|exact(?:-integer-sqrt|-integer\?|\?)?|expt|features|file-error\?|floor(?:-quotient|-remainder|\/)?|flush-output-port|for-each|gcd|get-output-(?:bytevector|string)|inexact\??|input-port(?:-open\?|\?)|integer(?:->char|\?)|lcm|length|list(?:->string|->vector|-copy|-ref|-set!|-tail|\?)?|make-(?:bytevector|list|parameter|string|vector)|map|max|member|memq|memv|min|modulo|negative\?|newline|not|null\?|number(?:->string|\?)|numerator|odd\?|open-(?:input|output)-(?:bytevector|string)|or|output-port(?:-open\?|\?)|pair\?|peek-char|peek-u8|port\?|positive\?|procedure\?|quotient|raise|raise-continuable|rational\?|rationalize|read-(?:bytevector|bytevector!|char|error\?|line|string|u8)|real\?|remainder|reverse|round|set-c[ad]r!|square|string(?:->list|->number|->symbol|->utf8|->vector|-append|-copy|-copy!|-fill!|-for-each|-length|-map|-ref|-set!|\?|<\?|<=\?|=\?|>\?|>=\?)?|substring|symbol(?:->string|\?|=\?)|syntax-error|textual-port\?|truncate(?:-quotient|-remainder|\/)?|u8-ready\?|utf8->string|values|vector(?:->list|->string|-append|-copy|-copy!|-fill!|-for-each|-length|-map|-ref|-set!|\?)?|with-exception-handler|write-(?:bytevector|char|string|u8)|zero\?)(?=[()\[\]\s]|$)/,lookbehind:!0},operator:{pattern:/((?:^|[^'`#])[(\[])(?:[-+*%/]|[<>]=?|=>?)(?=[()\[\]\s]|$)/,lookbehind:!0},number:{pattern:RegExp(function(e){for(var r in e)e[r]=e[r].replace(/<[\w\s]+>/g,(function(r){return"(?:"+e[r].trim()+")"}));return e[r]}({"":"\\d+(?:/\\d+)|(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:[esfdl][+-]?\\d+)?","":"[+-]?|[+-](?:inf|nan)\\.0","":"[+-](?:|(?:inf|nan)\\.0)?i","":"(?:@|)?|","":"(?:#d(?:#[ei])?|#[ei](?:#d)?)?","":"[0-9a-f]+(?:/[0-9a-f]+)?","":"[+-]?|[+-](?:inf|nan)\\.0","":"[+-](?:|(?:inf|nan)\\.0)?i","":"(?:@|)?|","":"#[box](?:#[ei])?|(?:#[ei])?#[box]","":"(^|[()\\[\\]\\s])(?:|)(?=[()\\[\\]\\s]|$)"}),"i"),lookbehind:!0},boolean:{pattern:/(^|[()\[\]\s])#(?:[ft]|false|true)(?=[()\[\]\s]|$)/,lookbehind:!0},function:{pattern:/((?:^|[^'`#])[(\[])(?:[^|()\[\]'\s]+|\|(?:[^\\|]|\\.)*\|)(?=[()\[\]\s]|$)/,lookbehind:!0},identifier:{pattern:/(^|[()\[\]\s])\|(?:[^\\|]|\\.)*\|(?=[()\[\]\s]|$)/,lookbehind:!0,greedy:!0},punctuation:/[()\[\]']/}}(Prism); +!function(e){var a=[/\b(?:async|sync|yield)\*/,/\b(?:abstract|assert|async|await|break|case|catch|class|const|continue|covariant|default|deferred|do|dynamic|else|enum|export|extends|extension|external|factory|final|finally|for|get|hide|if|implements|import|in|interface|library|mixin|new|null|on|operator|part|rethrow|return|set|show|static|super|switch|sync|this|throw|try|typedef|var|void|while|with|yield)\b/],n="(^|[^\\w.])(?:[a-z]\\w*\\s*\\.\\s*)*(?:[A-Z]\\w*\\s*\\.\\s*)*",s={pattern:RegExp(n+"[A-Z](?:[\\d_A-Z]*[a-z]\\w*)?\\b"),lookbehind:!0,inside:{namespace:{pattern:/^[a-z]\w*(?:\s*\.\s*[a-z]\w*)*(?:\s*\.)?/,inside:{punctuation:/\./}}}};e.languages.dart=e.languages.extend("clike",{"class-name":[s,{pattern:RegExp(n+"[A-Z]\\w*(?=\\s+\\w+\\s*[;,=()])"),lookbehind:!0,inside:s.inside}],keyword:a,operator:/\bis!|\b(?:as|is)\b|\+\+|--|&&|\|\||<<=?|>>=?|~(?:\/=?)?|[+\-*\/%&^|=!<>]=?|\?/}),e.languages.insertBefore("dart","string",{"string-literal":{pattern:/r?(?:("""|''')[\s\S]*?\1|(["'])(?:\\.|(?!\2)[^\\\r\n])*\2(?!\2))/,greedy:!0,inside:{interpolation:{pattern:/((?:^|[^\\])(?:\\{2})*)\$(?:\w+|\{(?:[^{}]|\{[^{}]*\})*\})/,lookbehind:!0,inside:{punctuation:/^\$\{?|\}$/,expression:{pattern:/[\s\S]+/,inside:e.languages.dart}}},string:/[\s\S]+/}},string:void 0}),e.languages.insertBefore("dart","class-name",{metadata:{pattern:/@\w+/,alias:"function"}}),e.languages.insertBefore("dart","class-name",{generics:{pattern:/<(?:[\w\s,.&?]|<(?:[\w\s,.&?]|<(?:[\w\s,.&?]|<[\w\s,.&?]*>)*>)*>)*>/,inside:{"class-name":s,keyword:a,punctuation:/[<>(),.:]/,operator:/[?&|]/}}})}(Prism); +!function(e){e.languages.crystal=e.languages.extend("ruby",{keyword:[/\b(?:__DIR__|__END_LINE__|__FILE__|__LINE__|abstract|alias|annotation|as|asm|begin|break|case|class|def|do|else|elsif|end|ensure|enum|extend|for|fun|if|ifdef|include|instance_sizeof|lib|macro|module|next|of|out|pointerof|private|protected|ptr|require|rescue|return|select|self|sizeof|struct|super|then|type|typeof|undef|uninitialized|union|unless|until|when|while|with|yield)\b/,{pattern:/(\.\s*)(?:is_a|responds_to)\?/,lookbehind:!0}],number:/\b(?:0b[01_]*[01]|0o[0-7_]*[0-7]|0x[\da-fA-F_]*[\da-fA-F]|(?:\d(?:[\d_]*\d)?)(?:\.[\d_]*\d)?(?:[eE][+-]?[\d_]*\d)?)(?:_(?:[uif](?:8|16|32|64))?)?\b/,operator:[/->/,e.languages.ruby.operator],punctuation:/[(){}[\].,;\\]/}),e.languages.insertBefore("crystal","string-literal",{attribute:{pattern:/@\[.*?\]/,inside:{delimiter:{pattern:/^@\[|\]$/,alias:"punctuation"},attribute:{pattern:/^(\s*)\w+/,lookbehind:!0,alias:"class-name"},args:{pattern:/\S(?:[\s\S]*\S)?/,inside:e.languages.crystal}}},expansion:{pattern:/\{(?:\{.*?\}|%.*?%)\}/,inside:{content:{pattern:/^(\{.)[\s\S]+(?=.\}$)/,lookbehind:!0,inside:e.languages.crystal},delimiter:{pattern:/^\{[\{%]|[\}%]\}$/,alias:"operator"}}},char:{pattern:/'(?:[^\\\r\n]{1,2}|\\(?:.|u(?:[A-Fa-f0-9]{1,4}|\{[A-Fa-f0-9]{1,6}\})))'/,greedy:!0}})}(Prism); +!function(e){var t="\\b(?:BASH|BASHOPTS|BASH_ALIASES|BASH_ARGC|BASH_ARGV|BASH_CMDS|BASH_COMPLETION_COMPAT_DIR|BASH_LINENO|BASH_REMATCH|BASH_SOURCE|BASH_VERSINFO|BASH_VERSION|COLORTERM|COLUMNS|COMP_WORDBREAKS|DBUS_SESSION_BUS_ADDRESS|DEFAULTS_PATH|DESKTOP_SESSION|DIRSTACK|DISPLAY|EUID|GDMSESSION|GDM_LANG|GNOME_KEYRING_CONTROL|GNOME_KEYRING_PID|GPG_AGENT_INFO|GROUPS|HISTCONTROL|HISTFILE|HISTFILESIZE|HISTSIZE|HOME|HOSTNAME|HOSTTYPE|IFS|INSTANCE|JOB|LANG|LANGUAGE|LC_ADDRESS|LC_ALL|LC_IDENTIFICATION|LC_MEASUREMENT|LC_MONETARY|LC_NAME|LC_NUMERIC|LC_PAPER|LC_TELEPHONE|LC_TIME|LESSCLOSE|LESSOPEN|LINES|LOGNAME|LS_COLORS|MACHTYPE|MAILCHECK|MANDATORY_PATH|NO_AT_BRIDGE|OLDPWD|OPTERR|OPTIND|ORBIT_SOCKETDIR|OSTYPE|PAPERSIZE|PATH|PIPESTATUS|PPID|PS1|PS2|PS3|PS4|PWD|RANDOM|REPLY|SECONDS|SELINUX_INIT|SESSION|SESSIONTYPE|SESSION_MANAGER|SHELL|SHELLOPTS|SHLVL|SSH_AUTH_SOCK|TERM|UID|UPSTART_EVENTS|UPSTART_INSTANCE|UPSTART_JOB|UPSTART_SESSION|USER|WINDOWID|XAUTHORITY|XDG_CONFIG_DIRS|XDG_CURRENT_DESKTOP|XDG_DATA_DIRS|XDG_GREETER_DATA_DIR|XDG_MENU_PREFIX|XDG_RUNTIME_DIR|XDG_SEAT|XDG_SEAT_PATH|XDG_SESSION_DESKTOP|XDG_SESSION_ID|XDG_SESSION_PATH|XDG_SESSION_TYPE|XDG_VTNR|XMODIFIERS)\\b",a={pattern:/(^(["']?)\w+\2)[ \t]+\S.*/,lookbehind:!0,alias:"punctuation",inside:null},n={bash:a,environment:{pattern:RegExp("\\$"+t),alias:"constant"},variable:[{pattern:/\$?\(\([\s\S]+?\)\)/,greedy:!0,inside:{variable:[{pattern:/(^\$\(\([\s\S]+)\)\)/,lookbehind:!0},/^\$\(\(/],number:/\b0x[\dA-Fa-f]+\b|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:[Ee]-?\d+)?/,operator:/--|\+\+|\*\*=?|<<=?|>>=?|&&|\|\||[=!+\-*/%<>^&|]=?|[?~:]/,punctuation:/\(\(?|\)\)?|,|;/}},{pattern:/\$\((?:\([^)]+\)|[^()])+\)|`[^`]+`/,greedy:!0,inside:{variable:/^\$\(|^`|\)$|`$/}},{pattern:/\$\{[^}]+\}/,greedy:!0,inside:{operator:/:[-=?+]?|[!\/]|##?|%%?|\^\^?|,,?/,punctuation:/[\[\]]/,environment:{pattern:RegExp("(\\{)"+t),lookbehind:!0,alias:"constant"}}},/\$(?:\w+|[#?*!@$])/],entity:/\\(?:[abceEfnrtv\\"]|O?[0-7]{1,3}|U[0-9a-fA-F]{8}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{1,2})/};e.languages.bash={shebang:{pattern:/^#!\s*\/.*/,alias:"important"},comment:{pattern:/(^|[^"{\\$])#.*/,lookbehind:!0},"function-name":[{pattern:/(\bfunction\s+)[\w-]+(?=(?:\s*\(?:\s*\))?\s*\{)/,lookbehind:!0,alias:"function"},{pattern:/\b[\w-]+(?=\s*\(\s*\)\s*\{)/,alias:"function"}],"for-or-select":{pattern:/(\b(?:for|select)\s+)\w+(?=\s+in\s)/,alias:"variable",lookbehind:!0},"assign-left":{pattern:/(^|[\s;|&]|[<>]\()\w+(?:\.\w+)*(?=\+?=)/,inside:{environment:{pattern:RegExp("(^|[\\s;|&]|[<>]\\()"+t),lookbehind:!0,alias:"constant"}},alias:"variable",lookbehind:!0},parameter:{pattern:/(^|\s)-{1,2}(?:\w+:[+-]?)?\w+(?:\.\w+)*(?=[=\s]|$)/,alias:"variable",lookbehind:!0},string:[{pattern:/((?:^|[^<])<<-?\s*)(\w+)\s[\s\S]*?(?:\r?\n|\r)\2/,lookbehind:!0,greedy:!0,inside:n},{pattern:/((?:^|[^<])<<-?\s*)(["'])(\w+)\2\s[\s\S]*?(?:\r?\n|\r)\3/,lookbehind:!0,greedy:!0,inside:{bash:a}},{pattern:/(^|[^\\](?:\\\\)*)"(?:\\[\s\S]|\$\([^)]+\)|\$(?!\()|`[^`]+`|[^"\\`$])*"/,lookbehind:!0,greedy:!0,inside:n},{pattern:/(^|[^$\\])'[^']*'/,lookbehind:!0,greedy:!0},{pattern:/\$'(?:[^'\\]|\\[\s\S])*'/,greedy:!0,inside:{entity:n.entity}}],environment:{pattern:RegExp("\\$?"+t),alias:"constant"},variable:n.variable,function:{pattern:/(^|[\s;|&]|[<>]\()(?:add|apropos|apt|apt-cache|apt-get|aptitude|aspell|automysqlbackup|awk|basename|bash|bc|bconsole|bg|bzip2|cal|cargo|cat|cfdisk|chgrp|chkconfig|chmod|chown|chroot|cksum|clear|cmp|column|comm|composer|cp|cron|crontab|csplit|curl|cut|date|dc|dd|ddrescue|debootstrap|df|diff|diff3|dig|dir|dircolors|dirname|dirs|dmesg|docker|docker-compose|du|egrep|eject|env|ethtool|expand|expect|expr|fdformat|fdisk|fg|fgrep|file|find|fmt|fold|format|free|fsck|ftp|fuser|gawk|git|gparted|grep|groupadd|groupdel|groupmod|groups|grub-mkconfig|gzip|halt|head|hg|history|host|hostname|htop|iconv|id|ifconfig|ifdown|ifup|import|install|ip|java|jobs|join|kill|killall|less|link|ln|locate|logname|logrotate|look|lpc|lpr|lprint|lprintd|lprintq|lprm|ls|lsof|lynx|make|man|mc|mdadm|mkconfig|mkdir|mke2fs|mkfifo|mkfs|mkisofs|mknod|mkswap|mmv|more|most|mount|mtools|mtr|mutt|mv|nano|nc|netstat|nice|nl|node|nohup|notify-send|npm|nslookup|op|open|parted|passwd|paste|pathchk|ping|pkill|pnpm|podman|podman-compose|popd|pr|printcap|printenv|ps|pushd|pv|quota|quotacheck|quotactl|ram|rar|rcp|reboot|remsync|rename|renice|rev|rm|rmdir|rpm|rsync|scp|screen|sdiff|sed|sendmail|seq|service|sftp|sh|shellcheck|shuf|shutdown|sleep|slocate|sort|split|ssh|stat|strace|su|sudo|sum|suspend|swapon|sync|sysctl|tac|tail|tar|tee|time|timeout|top|touch|tr|traceroute|tsort|tty|umount|uname|unexpand|uniq|units|unrar|unshar|unzip|update-grub|uptime|useradd|userdel|usermod|users|uudecode|uuencode|v|vcpkg|vdir|vi|vim|virsh|vmstat|wait|watch|wc|wget|whereis|which|who|whoami|write|xargs|xdg-open|yarn|yes|zenity|zip|zsh|zypper)(?=$|[)\s;|&])/,lookbehind:!0},keyword:{pattern:/(^|[\s;|&]|[<>]\()(?:case|do|done|elif|else|esac|fi|for|function|if|in|select|then|until|while)(?=$|[)\s;|&])/,lookbehind:!0},builtin:{pattern:/(^|[\s;|&]|[<>]\()(?:\.|:|alias|bind|break|builtin|caller|cd|command|continue|declare|echo|enable|eval|exec|exit|export|getopts|hash|help|let|local|logout|mapfile|printf|pwd|read|readarray|readonly|return|set|shift|shopt|source|test|times|trap|type|typeset|ulimit|umask|unalias|unset)(?=$|[)\s;|&])/,lookbehind:!0,alias:"class-name"},boolean:{pattern:/(^|[\s;|&]|[<>]\()(?:false|true)(?=$|[)\s;|&])/,lookbehind:!0},"file-descriptor":{pattern:/\B&\d\b/,alias:"important"},operator:{pattern:/\d?<>|>\||\+=|=[=~]?|!=?|<<[<-]?|[&\d]?>>|\d[<>]&?|[<>][&=]?|&[>&]?|\|[&|]?/,inside:{"file-descriptor":{pattern:/^\d/,alias:"important"}}},punctuation:/\$?\(\(?|\)\)?|\.\.|[{}[\];\\]/,number:{pattern:/(^|\s)(?:[1-9]\d*|0)(?:[.,]\d+)?\b/,lookbehind:!0}},a.inside=e.languages.bash;for(var s=["comment","function-name","for-or-select","assign-left","parameter","string","environment","function","keyword","builtin","boolean","file-descriptor","operator","punctuation","number"],o=n.variable[1].inside,i=0;i/,lookbehind:!0},{pattern:/(^|[^`])#.*/,lookbehind:!0}],string:[{pattern:/"(?:`[\s\S]|[^`"])*"/,greedy:!0,inside:null},{pattern:/'(?:[^']|'')*'/,greedy:!0}],namespace:/\[[a-z](?:\[(?:\[[^\]]*\]|[^\[\]])*\]|[^\[\]])*\]/i,boolean:/\$(?:false|true)\b/i,variable:/\$\w+\b/,function:[/\b(?:Add|Approve|Assert|Backup|Block|Checkpoint|Clear|Close|Compare|Complete|Compress|Confirm|Connect|Convert|ConvertFrom|ConvertTo|Copy|Debug|Deny|Disable|Disconnect|Dismount|Edit|Enable|Enter|Exit|Expand|Export|Find|ForEach|Format|Get|Grant|Group|Hide|Import|Initialize|Install|Invoke|Join|Limit|Lock|Measure|Merge|Move|New|Open|Optimize|Out|Ping|Pop|Protect|Publish|Push|Read|Receive|Redo|Register|Remove|Rename|Repair|Request|Reset|Resize|Resolve|Restart|Restore|Resume|Revoke|Save|Search|Select|Send|Set|Show|Skip|Sort|Split|Start|Step|Stop|Submit|Suspend|Switch|Sync|Tee|Test|Trace|Unblock|Undo|Uninstall|Unlock|Unprotect|Unpublish|Unregister|Update|Use|Wait|Watch|Where|Write)-[a-z]+\b/i,/\b(?:ac|cat|chdir|clc|cli|clp|clv|compare|copy|cp|cpi|cpp|cvpa|dbp|del|diff|dir|ebp|echo|epal|epcsv|epsn|erase|fc|fl|ft|fw|gal|gbp|gc|gci|gcs|gdr|gi|gl|gm|gp|gps|group|gsv|gu|gv|gwmi|iex|ii|ipal|ipcsv|ipsn|irm|iwmi|iwr|kill|lp|ls|measure|mi|mount|move|mp|mv|nal|ndr|ni|nv|ogv|popd|ps|pushd|pwd|rbp|rd|rdr|ren|ri|rm|rmdir|rni|rnp|rp|rv|rvpa|rwmi|sal|saps|sasv|sbp|sc|select|set|shcm|si|sl|sleep|sls|sort|sp|spps|spsv|start|sv|swmi|tee|trcm|type|write)\b/i],keyword:/\b(?:Begin|Break|Catch|Class|Continue|Data|Define|Do|DynamicParam|Else|ElseIf|End|Exit|Filter|Finally|For|ForEach|From|Function|If|InlineScript|Parallel|Param|Process|Return|Sequence|Switch|Throw|Trap|Try|Until|Using|Var|While|Workflow)\b/i,operator:{pattern:/(^|\W)(?:!|-(?:b?(?:and|x?or)|as|(?:Not)?(?:Contains|In|Like|Match)|eq|ge|gt|is(?:Not)?|Join|le|lt|ne|not|Replace|sh[lr])\b|-[-=]?|\+[+=]?|[*\/%]=?)/i,lookbehind:!0},punctuation:/[|{}[\];(),.]/};i.string[0].inside={function:{pattern:/(^|[^`])\$\((?:\$\([^\r\n()]*\)|(?!\$\()[^\r\n)])*\)/,lookbehind:!0,inside:i},boolean:i.boolean,variable:i.variable}}(Prism); +Prism.languages.json={property:{pattern:/(^|[^\\])"(?:\\.|[^\\"\r\n])*"(?=\s*:)/,lookbehind:!0,greedy:!0},string:{pattern:/(^|[^\\])"(?:\\.|[^\\"\r\n])*"(?!\s*:)/,lookbehind:!0,greedy:!0},comment:{pattern:/\/\/.*|\/\*[\s\S]*?(?:\*\/|$)/,greedy:!0},number:/-?\b\d+(?:\.\d+)?(?:e[+-]?\d+)?\b/i,punctuation:/[{}[\],]/,operator:/:/,boolean:/\b(?:false|true)\b/,null:{pattern:/\bnull\b/,alias:"keyword"}},Prism.languages.webmanifest=Prism.languages.json; +!function(e){var n=/[*&][^\s[\]{},]+/,r=/!(?:<[\w\-%#;/?:@&=+$,.!~*'()[\]]+>|(?:[a-zA-Z\d-]*!)?[\w\-%#;/?:@&=+$.~*'()]+)?/,t="(?:"+r.source+"(?:[ \t]+"+n.source+")?|"+n.source+"(?:[ \t]+"+r.source+")?)",a="(?:[^\\s\\x00-\\x08\\x0e-\\x1f!\"#%&'*,\\-:>?@[\\]`{|}\\x7f-\\x84\\x86-\\x9f\\ud800-\\udfff\\ufffe\\uffff]|[?:-])(?:[ \t]*(?:(?![#:])|:))*".replace(//g,(function(){return"[^\\s\\x00-\\x08\\x0e-\\x1f,[\\]{}\\x7f-\\x84\\x86-\\x9f\\ud800-\\udfff\\ufffe\\uffff]"})),d="\"(?:[^\"\\\\\r\n]|\\\\.)*\"|'(?:[^'\\\\\r\n]|\\\\.)*'";function o(e,n){n=(n||"").replace(/m/g,"")+"m";var r="([:\\-,[{]\\s*(?:\\s<>[ \t]+)?)(?:<>)(?=[ \t]*(?:$|,|\\]|\\}|(?:[\r\n]\\s*)?#))".replace(/<>/g,(function(){return t})).replace(/<>/g,(function(){return e}));return RegExp(r,n)}e.languages.yaml={scalar:{pattern:RegExp("([\\-:]\\s*(?:\\s<>[ \t]+)?[|>])[ \t]*(?:((?:\r?\n|\r)[ \t]+)\\S[^\r\n]*(?:\\2[^\r\n]+)*)".replace(/<>/g,(function(){return t}))),lookbehind:!0,alias:"string"},comment:/#.*/,key:{pattern:RegExp("((?:^|[:\\-,[{\r\n?])[ \t]*(?:<>[ \t]+)?)<>(?=\\s*:\\s)".replace(/<>/g,(function(){return t})).replace(/<>/g,(function(){return"(?:"+a+"|"+d+")"}))),lookbehind:!0,greedy:!0,alias:"atrule"},directive:{pattern:/(^[ \t]*)%.+/m,lookbehind:!0,alias:"important"},datetime:{pattern:o("\\d{4}-\\d\\d?-\\d\\d?(?:[tT]|[ \t]+)\\d\\d?:\\d{2}:\\d{2}(?:\\.\\d*)?(?:[ \t]*(?:Z|[-+]\\d\\d?(?::\\d{2})?))?|\\d{4}-\\d{2}-\\d{2}|\\d\\d?:\\d{2}(?::\\d{2}(?:\\.\\d*)?)?"),lookbehind:!0,alias:"number"},boolean:{pattern:o("false|true","i"),lookbehind:!0,alias:"important"},null:{pattern:o("null|~","i"),lookbehind:!0,alias:"important"},string:{pattern:o(d),lookbehind:!0,greedy:!0},number:{pattern:o("[+-]?(?:0x[\\da-f]+|0o[0-7]+|(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:e[+-]?\\d+)?|\\.inf|\\.nan)","i"),lookbehind:!0},tag:r,important:n,punctuation:/---|[:[\]{}\-,|>?]|\.\.\./},e.languages.yml=e.languages.yaml}(Prism); +!function(e){function n(e){return e.replace(/__/g,(function(){return"(?:[\\w-]+|'[^'\n\r]*'|\"(?:\\\\.|[^\\\\\"\r\n])*\")"}))}e.languages.toml={comment:{pattern:/#.*/,greedy:!0},table:{pattern:RegExp(n("(^[\t ]*\\[\\s*(?:\\[\\s*)?)__(?:\\s*\\.\\s*__)*(?=\\s*\\])"),"m"),lookbehind:!0,greedy:!0,alias:"class-name"},key:{pattern:RegExp(n("(^[\t ]*|[{,]\\s*)__(?:\\s*\\.\\s*__)*(?=\\s*=)"),"m"),lookbehind:!0,greedy:!0,alias:"property"},string:{pattern:/"""(?:\\[\s\S]|[^\\])*?"""|'''[\s\S]*?'''|'[^'\n\r]*'|"(?:\\.|[^\\"\r\n])*"/,greedy:!0},date:[{pattern:/\b\d{4}-\d{2}-\d{2}(?:[T\s]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?)?\b/i,alias:"number"},{pattern:/\b\d{2}:\d{2}:\d{2}(?:\.\d+)?\b/,alias:"number"}],number:/(?:\b0(?:x[\da-zA-Z]+(?:_[\da-zA-Z]+)*|o[0-7]+(?:_[0-7]+)*|b[10]+(?:_[10]+)*))\b|[-+]?\b\d+(?:_\d+)*(?:\.\d+(?:_\d+)*)?(?:[eE][+-]?\d+(?:_\d+)*)?\b|[-+]?\b(?:inf|nan)\b/,boolean:/\b(?:false|true)\b/,punctuation:/[.,=[\]{}]/}}(Prism); +Prism.languages.sql={comment:{pattern:/(^|[^\\])(?:\/\*[\s\S]*?\*\/|(?:--|\/\/|#).*)/,lookbehind:!0},variable:[{pattern:/@(["'`])(?:\\[\s\S]|(?!\1)[^\\])+\1/,greedy:!0},/@[\w.$]+/],string:{pattern:/(^|[^@\\])("|')(?:\\[\s\S]|(?!\2)[^\\]|\2\2)*\2/,greedy:!0,lookbehind:!0},identifier:{pattern:/(^|[^@\\])`(?:\\[\s\S]|[^`\\]|``)*`/,greedy:!0,lookbehind:!0,inside:{punctuation:/^`|`$/}},function:/\b(?:AVG|COUNT|FIRST|FORMAT|LAST|LCASE|LEN|MAX|MID|MIN|MOD|NOW|ROUND|SUM|UCASE)(?=\s*\()/i,keyword:/\b(?:ACTION|ADD|AFTER|ALGORITHM|ALL|ALTER|ANALYZE|ANY|APPLY|AS|ASC|AUTHORIZATION|AUTO_INCREMENT|BACKUP|BDB|BEGIN|BERKELEYDB|BIGINT|BINARY|BIT|BLOB|BOOL|BOOLEAN|BREAK|BROWSE|BTREE|BULK|BY|CALL|CASCADED?|CASE|CHAIN|CHAR(?:ACTER|SET)?|CHECK(?:POINT)?|CLOSE|CLUSTERED|COALESCE|COLLATE|COLUMNS?|COMMENT|COMMIT(?:TED)?|COMPUTE|CONNECT|CONSISTENT|CONSTRAINT|CONTAINS(?:TABLE)?|CONTINUE|CONVERT|CREATE|CROSS|CURRENT(?:_DATE|_TIME|_TIMESTAMP|_USER)?|CURSOR|CYCLE|DATA(?:BASES?)?|DATE(?:TIME)?|DAY|DBCC|DEALLOCATE|DEC|DECIMAL|DECLARE|DEFAULT|DEFINER|DELAYED|DELETE|DELIMITERS?|DENY|DESC|DESCRIBE|DETERMINISTIC|DISABLE|DISCARD|DISK|DISTINCT|DISTINCTROW|DISTRIBUTED|DO|DOUBLE|DROP|DUMMY|DUMP(?:FILE)?|DUPLICATE|ELSE(?:IF)?|ENABLE|ENCLOSED|END|ENGINE|ENUM|ERRLVL|ERRORS|ESCAPED?|EXCEPT|EXEC(?:UTE)?|EXISTS|EXIT|EXPLAIN|EXTENDED|FETCH|FIELDS|FILE|FILLFACTOR|FIRST|FIXED|FLOAT|FOLLOWING|FOR(?: EACH ROW)?|FORCE|FOREIGN|FREETEXT(?:TABLE)?|FROM|FULL|FUNCTION|GEOMETRY(?:COLLECTION)?|GLOBAL|GOTO|GRANT|GROUP|HANDLER|HASH|HAVING|HOLDLOCK|HOUR|IDENTITY(?:COL|_INSERT)?|IF|IGNORE|IMPORT|INDEX|INFILE|INNER|INNODB|INOUT|INSERT|INT|INTEGER|INTERSECT|INTERVAL|INTO|INVOKER|ISOLATION|ITERATE|JOIN|KEYS?|KILL|LANGUAGE|LAST|LEAVE|LEFT|LEVEL|LIMIT|LINENO|LINES|LINESTRING|LOAD|LOCAL|LOCK|LONG(?:BLOB|TEXT)|LOOP|MATCH(?:ED)?|MEDIUM(?:BLOB|INT|TEXT)|MERGE|MIDDLEINT|MINUTE|MODE|MODIFIES|MODIFY|MONTH|MULTI(?:LINESTRING|POINT|POLYGON)|NATIONAL|NATURAL|NCHAR|NEXT|NO|NONCLUSTERED|NULLIF|NUMERIC|OFF?|OFFSETS?|ON|OPEN(?:DATASOURCE|QUERY|ROWSET)?|OPTIMIZE|OPTION(?:ALLY)?|ORDER|OUT(?:ER|FILE)?|OVER|PARTIAL|PARTITION|PERCENT|PIVOT|PLAN|POINT|POLYGON|PRECEDING|PRECISION|PREPARE|PREV|PRIMARY|PRINT|PRIVILEGES|PROC(?:EDURE)?|PUBLIC|PURGE|QUICK|RAISERROR|READS?|REAL|RECONFIGURE|REFERENCES|RELEASE|RENAME|REPEAT(?:ABLE)?|REPLACE|REPLICATION|REQUIRE|RESIGNAL|RESTORE|RESTRICT|RETURN(?:ING|S)?|REVOKE|RIGHT|ROLLBACK|ROUTINE|ROW(?:COUNT|GUIDCOL|S)?|RTREE|RULE|SAVE(?:POINT)?|SCHEMA|SECOND|SELECT|SERIAL(?:IZABLE)?|SESSION(?:_USER)?|SET(?:USER)?|SHARE|SHOW|SHUTDOWN|SIMPLE|SMALLINT|SNAPSHOT|SOME|SONAME|SQL|START(?:ING)?|STATISTICS|STATUS|STRIPED|SYSTEM_USER|TABLES?|TABLESPACE|TEMP(?:ORARY|TABLE)?|TERMINATED|TEXT(?:SIZE)?|THEN|TIME(?:STAMP)?|TINY(?:BLOB|INT|TEXT)|TOP?|TRAN(?:SACTIONS?)?|TRIGGER|TRUNCATE|TSEQUAL|TYPES?|UNBOUNDED|UNCOMMITTED|UNDEFINED|UNION|UNIQUE|UNLOCK|UNPIVOT|UNSIGNED|UPDATE(?:TEXT)?|USAGE|USE|USER|USING|VALUES?|VAR(?:BINARY|CHAR|CHARACTER|YING)|VIEW|WAITFOR|WARNINGS|WHEN|WHERE|WHILE|WITH(?: ROLLUP|IN)?|WORK|WRITE(?:TEXT)?|YEAR)\b/i,boolean:/\b(?:FALSE|NULL|TRUE)\b/i,number:/\b0x[\da-f]+\b|\b\d+(?:\.\d*)?|\B\.\d+\b/i,operator:/[-+*\/=%^~]|&&?|\|\|?|!=?|<(?:=>?|<|>)?|>[>=]?|\b(?:AND|BETWEEN|DIV|ILIKE|IN|IS|LIKE|NOT|OR|REGEXP|RLIKE|SOUNDS LIKE|XOR)\b/i,punctuation:/[;[\]()`,.]/}; +!function(n){function e(n){return n=n.replace(//g,(function(){return"(?:\\\\.|[^\\\\\n\r]|(?:\n|\r\n?)(?![\r\n]))"})),RegExp("((?:^|[^\\\\])(?:\\\\{2})*)(?:"+n+")")}var t="(?:\\\\.|``(?:[^`\r\n]|`(?!`))+``|`[^`\r\n]+`|[^\\\\|\r\n`])+",a="\\|?__(?:\\|__)+\\|?(?:(?:\n|\r\n?)|(?![^]))".replace(/__/g,(function(){return t})),i="\\|?[ \t]*:?-{3,}:?[ \t]*(?:\\|[ \t]*:?-{3,}:?[ \t]*)+\\|?(?:\n|\r\n?)";n.languages.markdown=n.languages.extend("markup",{}),n.languages.insertBefore("markdown","prolog",{"front-matter-block":{pattern:/(^(?:\s*[\r\n])?)---(?!.)[\s\S]*?[\r\n]---(?!.)/,lookbehind:!0,greedy:!0,inside:{punctuation:/^---|---$/,"front-matter":{pattern:/\S+(?:\s+\S+)*/,alias:["yaml","language-yaml"],inside:n.languages.yaml}}},blockquote:{pattern:/^>(?:[\t ]*>)*/m,alias:"punctuation"},table:{pattern:RegExp("^"+a+i+"(?:"+a+")*","m"),inside:{"table-data-rows":{pattern:RegExp("^("+a+i+")(?:"+a+")*$"),lookbehind:!0,inside:{"table-data":{pattern:RegExp(t),inside:n.languages.markdown},punctuation:/\|/}},"table-line":{pattern:RegExp("^("+a+")"+i+"$"),lookbehind:!0,inside:{punctuation:/\||:?-{3,}:?/}},"table-header-row":{pattern:RegExp("^"+a+"$"),inside:{"table-header":{pattern:RegExp(t),alias:"important",inside:n.languages.markdown},punctuation:/\|/}}}},code:[{pattern:/((?:^|\n)[ \t]*\n|(?:^|\r\n?)[ \t]*\r\n?)(?: {4}|\t).+(?:(?:\n|\r\n?)(?: {4}|\t).+)*/,lookbehind:!0,alias:"keyword"},{pattern:/^```[\s\S]*?^```$/m,greedy:!0,inside:{"code-block":{pattern:/^(```.*(?:\n|\r\n?))[\s\S]+?(?=(?:\n|\r\n?)^```$)/m,lookbehind:!0},"code-language":{pattern:/^(```).+/,lookbehind:!0},punctuation:/```/}}],title:[{pattern:/\S.*(?:\n|\r\n?)(?:==+|--+)(?=[ \t]*$)/m,alias:"important",inside:{punctuation:/==+$|--+$/}},{pattern:/(^\s*)#.+/m,lookbehind:!0,alias:"important",inside:{punctuation:/^#+|#+$/}}],hr:{pattern:/(^\s*)([*-])(?:[\t ]*\2){2,}(?=\s*$)/m,lookbehind:!0,alias:"punctuation"},list:{pattern:/(^\s*)(?:[*+-]|\d+\.)(?=[\t ].)/m,lookbehind:!0,alias:"punctuation"},"url-reference":{pattern:/!?\[[^\]]+\]:[\t ]+(?:\S+|<(?:\\.|[^>\\])+>)(?:[\t ]+(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\((?:\\.|[^)\\])*\)))?/,inside:{variable:{pattern:/^(!?\[)[^\]]+/,lookbehind:!0},string:/(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\((?:\\.|[^)\\])*\))$/,punctuation:/^[\[\]!:]|[<>]/},alias:"url"},bold:{pattern:e("\\b__(?:(?!_)|_(?:(?!_))+_)+__\\b|\\*\\*(?:(?!\\*)|\\*(?:(?!\\*))+\\*)+\\*\\*"),lookbehind:!0,greedy:!0,inside:{content:{pattern:/(^..)[\s\S]+(?=..$)/,lookbehind:!0,inside:{}},punctuation:/\*\*|__/}},italic:{pattern:e("\\b_(?:(?!_)|__(?:(?!_))+__)+_\\b|\\*(?:(?!\\*)|\\*\\*(?:(?!\\*))+\\*\\*)+\\*"),lookbehind:!0,greedy:!0,inside:{content:{pattern:/(^.)[\s\S]+(?=.$)/,lookbehind:!0,inside:{}},punctuation:/[*_]/}},strike:{pattern:e("(~~?)(?:(?!~))+\\2"),lookbehind:!0,greedy:!0,inside:{content:{pattern:/(^~~?)[\s\S]+(?=\1$)/,lookbehind:!0,inside:{}},punctuation:/~~?/}},"code-snippet":{pattern:/(^|[^\\`])(?:``[^`\r\n]+(?:`[^`\r\n]+)*``(?!`)|`[^`\r\n]+`(?!`))/,lookbehind:!0,greedy:!0,alias:["code","keyword"]},url:{pattern:e('!?\\[(?:(?!\\]))+\\](?:\\([^\\s)]+(?:[\t ]+"(?:\\\\.|[^"\\\\])*")?\\)|[ \t]?\\[(?:(?!\\]))+\\])'),lookbehind:!0,greedy:!0,inside:{operator:/^!/,content:{pattern:/(^\[)[^\]]+(?=\])/,lookbehind:!0,inside:{}},variable:{pattern:/(^\][ \t]?\[)[^\]]+(?=\]$)/,lookbehind:!0},url:{pattern:/(^\]\()[^\s)]+/,lookbehind:!0},string:{pattern:/(^[ \t]+)"(?:\\.|[^"\\])*"(?=\)$)/,lookbehind:!0}}}}),["url","bold","italic","strike"].forEach((function(e){["url","bold","italic","strike","code-snippet"].forEach((function(t){e!==t&&(n.languages.markdown[e].inside.content.inside[t]=n.languages.markdown[t])}))})),n.hooks.add("after-tokenize",(function(n){"markdown"!==n.language&&"md"!==n.language||function n(e){if(e&&"string"!=typeof e)for(var t=0,a=e.length;t",quot:'"'},l=String.fromCodePoint||String.fromCharCode;n.languages.md=n.languages.markdown}(Prism); +!function(a){var e=/\\(?:[^a-z()[\]]|[a-z*]+)/i,n={"equation-command":{pattern:e,alias:"regex"}};a.languages.latex={comment:/%.*/,cdata:{pattern:/(\\begin\{((?:lstlisting|verbatim)\*?)\})[\s\S]*?(?=\\end\{\2\})/,lookbehind:!0},equation:[{pattern:/\$\$(?:\\[\s\S]|[^\\$])+\$\$|\$(?:\\[\s\S]|[^\\$])+\$|\\\([\s\S]*?\\\)|\\\[[\s\S]*?\\\]/,inside:n,alias:"string"},{pattern:/(\\begin\{((?:align|eqnarray|equation|gather|math|multline)\*?)\})[\s\S]*?(?=\\end\{\2\})/,lookbehind:!0,inside:n,alias:"string"}],keyword:{pattern:/(\\(?:begin|cite|documentclass|end|label|ref|usepackage)(?:\[[^\]]+\])?\{)[^}]+(?=\})/,lookbehind:!0},url:{pattern:/(\\url\{)[^}]+(?=\})/,lookbehind:!0},headline:{pattern:/(\\(?:chapter|frametitle|paragraph|part|section|subparagraph|subsection|subsubparagraph|subsubsection|subsubsubparagraph)\*?(?:\[[^\]]+\])?\{)[^}]+(?=\})/,lookbehind:!0,alias:"class-name"},function:{pattern:e,alias:"selector"},punctuation:/[[\]{}&]/},a.languages.tex=a.languages.latex,a.languages.context=a.languages.latex}(Prism); +Prism.languages.wasm={comment:[/\(;[\s\S]*?;\)/,{pattern:/;;.*/,greedy:!0}],string:{pattern:/"(?:\\[\s\S]|[^"\\])*"/,greedy:!0},keyword:[{pattern:/\b(?:align|offset)=/,inside:{operator:/=/}},{pattern:/\b(?:(?:f32|f64|i32|i64)(?:\.(?:abs|add|and|ceil|clz|const|convert_[su]\/i(?:32|64)|copysign|ctz|demote\/f64|div(?:_[su])?|eqz?|extend_[su]\/i32|floor|ge(?:_[su])?|gt(?:_[su])?|le(?:_[su])?|load(?:(?:8|16|32)_[su])?|lt(?:_[su])?|max|min|mul|neg?|nearest|or|popcnt|promote\/f32|reinterpret\/[fi](?:32|64)|rem_[su]|rot[lr]|shl|shr_[su]|sqrt|store(?:8|16|32)?|sub|trunc(?:_[su]\/f(?:32|64))?|wrap\/i64|xor))?|memory\.(?:grow|size))\b/,inside:{punctuation:/\./}},/\b(?:anyfunc|block|br(?:_if|_table)?|call(?:_indirect)?|data|drop|elem|else|end|export|func|get_(?:global|local)|global|if|import|local|loop|memory|module|mut|nop|offset|param|result|return|select|set_(?:global|local)|start|table|tee_local|then|type|unreachable)\b/],variable:/\$[\w!#$%&'*+\-./:<=>?@\\^`|~]+/,number:/[+-]?\b(?:\d(?:_?\d)*(?:\.\d(?:_?\d)*)?(?:[eE][+-]?\d(?:_?\d)*)?|0x[\da-fA-F](?:_?[\da-fA-F])*(?:\.[\da-fA-F](?:_?[\da-fA-D])*)?(?:[pP][+-]?\d(?:_?\d)*)?)\b|\binf\b|\bnan(?::0x[\da-fA-F](?:_?[\da-fA-D])*)?\b/,punctuation:/[()]/}; +Prism.languages.wgsl={comment:{pattern:/\/\/.*|\/\*[\s\S]*?(?:\*\/|$)/,greedy:!0},"builtin-attribute":{pattern:/(@)builtin\(.*?\)/,lookbehind:!0,inside:{attribute:{pattern:/^builtin/,alias:"attr-name"},punctuation:/[(),]/,"built-in-values":{pattern:/\b(?:frag_depth|front_facing|global_invocation_id|instance_index|local_invocation_id|local_invocation_index|num_workgroups|position|sample_index|sample_mask|vertex_index|workgroup_id)\b/,alias:"attr-value"}}},attributes:{pattern:/(@)(?:align|binding|compute|const|fragment|group|id|interpolate|invariant|location|size|vertex|workgroup_size)/i,lookbehind:!0,alias:"attr-name"},functions:{pattern:/\b(fn\s+)[_a-zA-Z]\w*(?=[(<])/,lookbehind:!0,alias:"function"},keyword:/\b(?:bitcast|break|case|const|continue|continuing|default|discard|else|enable|fallthrough|fn|for|function|if|let|loop|private|return|storage|struct|switch|type|uniform|var|while|workgroup)\b/,builtin:/\b(?:abs|acos|acosh|all|any|array|asin|asinh|atan|atan2|atanh|atomic|atomicAdd|atomicAnd|atomicCompareExchangeWeak|atomicExchange|atomicLoad|atomicMax|atomicMin|atomicOr|atomicStore|atomicSub|atomicXor|bool|ceil|clamp|cos|cosh|countLeadingZeros|countOneBits|countTrailingZeros|cross|degrees|determinant|distance|dot|dpdx|dpdxCoarse|dpdxFine|dpdy|dpdyCoarse|dpdyFine|exp|exp2|extractBits|f32|f64|faceForward|firstLeadingBit|floor|fma|fract|frexp|fwidth|fwidthCoarse|fwidthFine|i32|i64|insertBits|inverseSqrt|ldexp|length|log|log2|mat[2-4]x[2-4]|max|min|mix|modf|normalize|override|pack2x16float|pack2x16snorm|pack2x16unorm|pack4x8snorm|pack4x8unorm|pow|ptr|quantizeToF16|radians|reflect|refract|reverseBits|round|sampler|sampler_comparison|select|shiftLeft|shiftRight|sign|sin|sinh|smoothstep|sqrt|staticAssert|step|storageBarrier|tan|tanh|textureDimensions|textureGather|textureGatherCompare|textureLoad|textureNumLayers|textureNumLevels|textureNumSamples|textureSample|textureSampleBias|textureSampleCompare|textureSampleCompareLevel|textureSampleGrad|textureSampleLevel|textureStore|texture_1d|texture_2d|texture_2d_array|texture_3d|texture_cube|texture_cube_array|texture_depth_2d|texture_depth_2d_array|texture_depth_cube|texture_depth_cube_array|texture_depth_multisampled_2d|texture_multisampled_2d|texture_storage_1d|texture_storage_2d|texture_storage_2d_array|texture_storage_3d|transpose|trunc|u32|u64|unpack2x16float|unpack2x16snorm|unpack2x16unorm|unpack4x8snorm|unpack4x8unorm|vec[2-4]|workgroupBarrier)\b/,"function-calls":{pattern:/\b[_a-z]\w*(?=\()/i,alias:"function"},"class-name":/\b(?:[A-Z][A-Za-z0-9]*)\b/,"bool-literal":{pattern:/\b(?:false|true)\b/,alias:"boolean"},"hex-int-literal":{pattern:/\b0[xX][0-9a-fA-F]+[iu]?\b(?![.pP])/,alias:"number"},"hex-float-literal":{pattern:/\b0[xX][0-9a-fA-F]*(?:\.[0-9a-fA-F]*)?(?:[pP][+-]?\d+[fh]?)?/,alias:"number"},"decimal-float-literal":[{pattern:/\d*\.\d+(?:[eE](?:\+|-)?\d+)?[fh]?/,alias:"number"},{pattern:/\d+\.\d*(?:[eE](?:\+|-)?\d+)?[fh]?/,alias:"number"},{pattern:/\d+[eE](?:\+|-)?\d+[fh]?/,alias:"number"},{pattern:/\b\d+[fh]\b/,alias:"number"}],"int-literal":{pattern:/\b\d+[iu]?\b/,alias:"number"},operator:[{pattern:/(?:\^|~|\|(?!\|)|\|\||&&|<<|>>|!)(?!=)/},{pattern:/&(?![&=])/},{pattern:/(?:\+=|-=|\*=|\/=|%=|\^=|&=|\|=|<<=|>>=)/},{pattern:/(^|[^<>=!])=(?![=>])/,lookbehind:!0},{pattern:/(?:==|!=|<=|\+\+|--|(^|[^=])>=)/,lookbehind:!0},{pattern:/(?:(?:[+%]|(?:\*(?!\w)))(?!=))|(?:-(?!>))|(?:\/(?!\/))/},{pattern:/->/}],punctuation:/[@(){}[\],;<>:.]/}; +Prism.languages.graphql={comment:/#.*/,description:{pattern:/(?:"""(?:[^"]|(?!""")")*"""|"(?:\\.|[^\\"\r\n])*")(?=\s*[a-z_])/i,greedy:!0,alias:"string",inside:{"language-markdown":{pattern:/(^"(?:"")?)(?!\1)[\s\S]+(?=\1$)/,lookbehind:!0,inside:Prism.languages.markdown}}},string:{pattern:/"""(?:[^"]|(?!""")")*"""|"(?:\\.|[^\\"\r\n])*"/,greedy:!0},number:/(?:\B-|\b)\d+(?:\.\d+)?(?:e[+-]?\d+)?\b/i,boolean:/\b(?:false|true)\b/,variable:/\$[a-z_]\w*/i,directive:{pattern:/@[a-z_]\w*/i,alias:"function"},"attr-name":{pattern:/\b[a-z_]\w*(?=\s*(?:\((?:[^()"]|"(?:\\.|[^\\"\r\n])*")*\))?:)/i,greedy:!0},"atom-input":{pattern:/\b[A-Z]\w*Input\b/,alias:"class-name"},scalar:/\b(?:Boolean|Float|ID|Int|String)\b/,constant:/\b[A-Z][A-Z_\d]*\b/,"class-name":{pattern:/(\b(?:enum|implements|interface|on|scalar|type|union)\s+|&\s*|:\s*|\[)[A-Z_]\w*/,lookbehind:!0},fragment:{pattern:/(\bfragment\s+|\.{3}\s*(?!on\b))[a-zA-Z_]\w*/,lookbehind:!0,alias:"function"},"definition-mutation":{pattern:/(\bmutation\s+)[a-zA-Z_]\w*/,lookbehind:!0,alias:"function"},"definition-query":{pattern:/(\bquery\s+)[a-zA-Z_]\w*/,lookbehind:!0,alias:"function"},keyword:/\b(?:directive|enum|extend|fragment|implements|input|interface|mutation|on|query|repeatable|scalar|schema|subscription|type|union)\b/,operator:/[!=|&]|\.{3}/,"property-query":/\w+(?=\s*\()/,object:/\w+(?=\s*\{)/,punctuation:/[!(){}\[\]:=,]/,property:/\w+/},Prism.hooks.add("after-tokenize",(function(n){if("graphql"===n.language)for(var t=n.tokens.filter((function(n){return"string"!=typeof n&&"comment"!==n.type&&"scalar"!==n.type})),e=0;e0)){var s=f(/^\{$/,/^\}$/);if(-1===s)continue;for(var u=e;u=0&&b(p,"variable-input")}}}}function l(n){return t[e+n]}function c(n,t){t=t||0;for(var e=0;e",unchanged:" ",diff:"!"};Object.keys(n).forEach((function(a){var i=n[a],r=[];/^\w+$/.test(a)||r.push(/\w+/.exec(a)[0]),"diff"===a&&r.push("bold"),e.languages.diff[a]={pattern:RegExp("^(?:["+i+"].*(?:\r\n?|\n|(?![\\s\\S])))+","m"),alias:r,inside:{line:{pattern:/(.)(?=[\s\S]).*(?:\r\n?|\n)?/,lookbehind:!0},prefix:{pattern:/[\s\S]/,alias:/\w+/.exec(a)[0]}}}})),Object.defineProperty(e.languages.diff,"PREFIXES",{value:n})}(Prism); +Prism.languages.git={comment:/^#.*/m,deleted:/^[-–].*/m,inserted:/^\+.*/m,string:/("|')(?:\\.|(?!\1)[^\\\r\n])*\1/,command:{pattern:/^.*\$ git .*$/m,inside:{parameter:/\s--?\w+/}},coord:/^@@.*@@$/m,"commit-sha1":/^commit \w{40}$/m}; +!function(t){function a(t){return RegExp("(^(?:"+t+"):[ \t]*(?![ \t]))[^]+","i")}t.languages.http={"request-line":{pattern:/^(?:CONNECT|DELETE|GET|HEAD|OPTIONS|PATCH|POST|PRI|PUT|SEARCH|TRACE)\s(?:https?:\/\/|\/)\S*\sHTTP\/[\d.]+/m,inside:{method:{pattern:/^[A-Z]+\b/,alias:"property"},"request-target":{pattern:/^(\s)(?:https?:\/\/|\/)\S*(?=\s)/,lookbehind:!0,alias:"url",inside:t.languages.uri},"http-version":{pattern:/^(\s)HTTP\/[\d.]+/,lookbehind:!0,alias:"property"}}},"response-status":{pattern:/^HTTP\/[\d.]+ \d+ .+/m,inside:{"http-version":{pattern:/^HTTP\/[\d.]+/,alias:"property"},"status-code":{pattern:/^(\s)\d+(?=\s)/,lookbehind:!0,alias:"number"},"reason-phrase":{pattern:/^(\s).+/,lookbehind:!0,alias:"string"}}},header:{pattern:/^[\w-]+:.+(?:(?:\r\n?|\n)[ \t].+)*/m,inside:{"header-value":[{pattern:a("Content-Security-Policy"),lookbehind:!0,alias:["csp","languages-csp"],inside:t.languages.csp},{pattern:a("Public-Key-Pins(?:-Report-Only)?"),lookbehind:!0,alias:["hpkp","languages-hpkp"],inside:t.languages.hpkp},{pattern:a("Strict-Transport-Security"),lookbehind:!0,alias:["hsts","languages-hsts"],inside:t.languages.hsts},{pattern:a("[^:]+"),lookbehind:!0}],"header-name":{pattern:/^[^:]+/,alias:"keyword"},punctuation:/^:/}}};var e,n=t.languages,s={"application/javascript":n.javascript,"application/json":n.json||n.javascript,"application/xml":n.xml,"text/xml":n.xml,"text/html":n.html,"text/css":n.css,"text/plain":n.plain},i={"application/json":!0,"application/xml":!0};function r(t){var a=t.replace(/^[a-z]+\//,"");return"(?:"+t+"|\\w+/(?:[\\w.-]+\\+)+"+a+"(?![+\\w.-]))"}for(var p in s)if(s[p]){e=e||{};var l=i[p]?r(p):p;e[p.replace(/\//g,"-")]={pattern:RegExp("(content-type:\\s*"+l+"(?:(?:\r\n?|\n)[\\w-].*)*(?:\r(?:\n|(?!\n))|\n))[^ \t\\w-][^]*","i"),lookbehind:!0,inside:s[p]}}e&&t.languages.insertBefore("http","header",e)}(Prism); +!function(a){var e={pattern:/\\[\\(){}[\]^$+*?|.]/,alias:"escape"},n=/\\(?:x[\da-fA-F]{2}|u[\da-fA-F]{4}|u\{[\da-fA-F]+\}|0[0-7]{0,2}|[123][0-7]{2}|c[a-zA-Z]|.)/,t="(?:[^\\\\-]|"+n.source+")",s=RegExp(t+"-"+t),i={pattern:/(<|')[^<>']+(?=[>']$)/,lookbehind:!0,alias:"variable"};a.languages.regex={"char-class":{pattern:/((?:^|[^\\])(?:\\\\)*)\[(?:[^\\\]]|\\[\s\S])*\]/,lookbehind:!0,inside:{"char-class-negation":{pattern:/(^\[)\^/,lookbehind:!0,alias:"operator"},"char-class-punctuation":{pattern:/^\[|\]$/,alias:"punctuation"},range:{pattern:s,inside:{escape:n,"range-punctuation":{pattern:/-/,alias:"operator"}}},"special-escape":e,"char-set":{pattern:/\\[wsd]|\\p\{[^{}]+\}/i,alias:"class-name"},escape:n}},"special-escape":e,"char-set":{pattern:/\.|\\[wsd]|\\p\{[^{}]+\}/i,alias:"class-name"},backreference:[{pattern:/\\(?![123][0-7]{2})[1-9]/,alias:"keyword"},{pattern:/\\k<[^<>']+>/,alias:"keyword",inside:{"group-name":i}}],anchor:{pattern:/[$^]|\\[ABbGZz]/,alias:"function"},escape:n,group:[{pattern:/\((?:\?(?:<[^<>']+>|'[^<>']+'|[>:]|)?|)".replace(//g,(function(){return"\\\\[\r\n](?:\\s|\\\\[\r\n]|#.*(?!.))*(?![\\s#]|\\\\[\r\n])"})),r="\"(?:[^\"\\\\\r\n]|\\\\(?:\r\n|[^]))*\"|'(?:[^'\\\\\r\n]|\\\\(?:\r\n|[^]))*'",t="--[\\w-]+=(?:|(?![\"'])(?:[^\\s\\\\]|\\\\.)+)".replace(//g,(function(){return r})),o={pattern:RegExp(r),greedy:!0},i={pattern:/(^[ \t]*)#.*/m,lookbehind:!0,greedy:!0};function a(e,r){return e=e.replace(//g,(function(){return t})).replace(//g,(function(){return n})),RegExp(e,r)}e.languages.docker={instruction:{pattern:/(^[ \t]*)(?:ADD|ARG|CMD|COPY|ENTRYPOINT|ENV|EXPOSE|FROM|HEALTHCHECK|LABEL|MAINTAINER|ONBUILD|RUN|SHELL|STOPSIGNAL|USER|VOLUME|WORKDIR)(?=\s)(?:\\.|[^\r\n\\])*(?:\\$(?:\s|#.*$)*(?![\s#])(?:\\.|[^\r\n\\])*)*/im,lookbehind:!0,greedy:!0,inside:{options:{pattern:a("(^(?:ONBUILD)?\\w+)(?:)*","i"),lookbehind:!0,greedy:!0,inside:{property:{pattern:/(^|\s)--[\w-]+/,lookbehind:!0},string:[o,{pattern:/(=)(?!["'])(?:[^\s\\]|\\.)+/,lookbehind:!0}],operator:/\\$/m,punctuation:/=/}},keyword:[{pattern:a("(^(?:ONBUILD)?HEALTHCHECK(?:)*)(?:CMD|NONE)\\b","i"),lookbehind:!0,greedy:!0},{pattern:a("(^(?:ONBUILD)?FROM(?:)*(?!--)[^ \t\\\\]+)AS","i"),lookbehind:!0,greedy:!0},{pattern:a("(^ONBUILD)\\w+","i"),lookbehind:!0,greedy:!0},{pattern:/^\w+/,greedy:!0}],comment:i,string:o,variable:/\$(?:\w+|\{[^{}"'\\]*\})/,operator:/\\$/m}},comment:i},e.languages.dockerfile=e.languages.docker}(Prism); +!function(e){var n=/\$(?:\w[a-z\d]*(?:_[^\x00-\x1F\s"'\\()$]*)?|\{[^}\s"'\\]+\})/i;e.languages.nginx={comment:{pattern:/(^|[\s{};])#.*/,lookbehind:!0,greedy:!0},directive:{pattern:/(^|\s)\w(?:[^;{}"'\\\s]|\\.|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\s+(?:#.*(?!.)|(?![#\s])))*?(?=\s*[;{])/,lookbehind:!0,greedy:!0,inside:{string:{pattern:/((?:^|[^\\])(?:\\\\)*)(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/,lookbehind:!0,greedy:!0,inside:{escape:{pattern:/\\["'\\nrt]/,alias:"entity"},variable:n}},comment:{pattern:/(\s)#.*/,lookbehind:!0,greedy:!0},keyword:{pattern:/^\S+/,greedy:!0},boolean:{pattern:/(\s)(?:off|on)(?!\S)/,lookbehind:!0},number:{pattern:/(\s)\d+[a-z]*(?!\S)/i,lookbehind:!0},variable:n}},punctuation:/[{};]/}}(Prism); + +// Helper function to flatten Prism tokens into simple objects +function flattenPrismTokens(tokens) { + var result = []; + + function flatten(token, parentType) { + if (typeof token === 'string') { + if (token.length > 0) { + result.push({ content: token, type: parentType || 'plain' }); + } + } else if (Array.isArray(token)) { + token.forEach(function(t) { flatten(t, parentType); }); + } else if (token && typeof token === 'object') { + var type = token.type || parentType || 'plain'; + if (typeof token.content === 'string') { + if (token.content.length > 0) { + result.push({ content: token.content, type: type }); + } + } else if (Array.isArray(token.content)) { + token.content.forEach(function(t) { flatten(t, type); }); + } else if (token.content && typeof token.content === 'object') { + flatten(token.content, type); + } + } + } + + tokens.forEach(function(token) { flatten(token, null); }); + return result; +} + +// Export tokenization function +function tokenizeCode(code, language) { + try { + var grammar = Prism.languages[language]; + if (!grammar) { + return [{ content: code, type: 'plain' }]; + } + var tokens = Prism.tokenize(code, grammar); + return flattenPrismTokens(tokens); + } catch (e) { + return [{ content: code, type: 'plain' }]; + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Image/Image.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Image/Image.swift new file mode 100644 index 000000000..e3cf94f38 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Image/Image.swift @@ -0,0 +1,141 @@ +import Foundation +import ImageIO + +// MARK: - Overview +// +// Image decodes static and animated images from data using CGImageSource. Supports GIF, APNG, +// WebP, and HEICS animation formats. +// +// Frame delay times follow WebKit's approach: prefer unclamped delay time if available, and +// enforce a minimum 100ms delay for any frames specifying ≤10ms (prevents CPU-intensive +// animations with extremely short frame durations). + +struct Image: Hashable, Sendable { + struct Frame: Hashable, Sendable { + var cgImage: CGImage + var delayTime: TimeInterval + } + + var frames: [Frame] + var loopCount: Int + var size: CGSize + + var cgImage: CGImage { frames[0].cgImage } + var isAnimated: Bool { frames.count > 1 } +} + +extension Image { + init?(data: Data) { + guard let cgImageSource = CGImageSourceCreateWithData(data as CFData, nil) else { + return nil + } + + switch CGImageSourceGetCount(cgImageSource) { + case 0: + return nil + case 1: + guard let cgImage = CGImageSourceCreateImageAtIndex(cgImageSource, 0, nil) else { + return nil + } + self.init(cgImage: cgImage) + default: + self.init(cgImageSource: cgImageSource) + } + } + + private init(cgImage: CGImage) { + self.init( + frames: [.init(cgImage: cgImage, delayTime: 0)], + loopCount: 0, + size: .init(width: cgImage.width, height: cgImage.height) + ) + } + + private init?(cgImageSource: CGImageSource) { + guard + let animationProperties = cgImageSource.animationProperties, + let frameInfo = animationProperties[.frameInfo] + else { + return nil + } + + let images = (0.. 0.010 ? value : 0.1 + } + + let canvasWidth = animationProperties[.canvasPixelWidth] + let canvasHeight = animationProperties[.canvasPixelHeight] + + let size = + if let canvasWidth, let canvasHeight { + CGSize(width: canvasWidth, height: canvasHeight) + } else { + CGSize(width: images[0].width, height: images[0].height) + } + + self.init( + frames: zip(images, delayTimes).map(Frame.init), + loopCount: animationProperties[.loopCount] ?? 0, + size: size + ) + } +} + +extension CGImageSource { + fileprivate struct ImageProperty: Sendable { + let key: String + } + + fileprivate var animationProperties: CFDictionary? { + guard let properties = CGImageSourceCopyProperties(self, nil) else { + return nil + } + + return properties[.gifDictionary] ?? properties[.pngDictionary] + ?? properties[.webPDictionary] ?? properties[.heicsDictionary] + } +} + +extension CFDictionary { + fileprivate subscript(_ imageProperty: CGImageSource.ImageProperty) -> Value? { + (self as NSDictionary).object(forKey: imageProperty.key) as? Value + } +} + +extension CGImageSource.ImageProperty where Value == CFDictionary { + fileprivate static let gifDictionary = Self(key: kCGImagePropertyGIFDictionary as String) + fileprivate static let pngDictionary = Self(key: kCGImagePropertyPNGDictionary as String) + fileprivate static let webPDictionary = Self(key: kCGImagePropertyWebPDictionary as String) + fileprivate static let heicsDictionary = Self(key: kCGImagePropertyHEICSDictionary as String) +} + +extension CGImageSource.ImageProperty where Value == [CFDictionary] { + static let frameInfo = Self(key: "FrameInfo") +} + +extension CGImageSource.ImageProperty where Value == TimeInterval { + static let delayTime = Self(key: "DelayTime") + static let unclampedDelayTime = Self(key: "UnclampedDelayTime") +} + +extension CGImageSource.ImageProperty where Value == Int { + static let loopCount = Self(key: "LoopCount") +} + +extension CGImageSource.ImageProperty where Value == CGFloat { + static let canvasPixelWidth = Self(key: "CanvasPixelWidth") + static let canvasPixelHeight = Self(key: "CanvasPixelHeight") +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Image/ImageLoader.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Image/ImageLoader.swift new file mode 100644 index 000000000..bd702dbd3 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Image/ImageLoader.swift @@ -0,0 +1,86 @@ +import Foundation + +// MARK: - Overview +// +// ImageLoader fetches and caches images with task deduplication. When multiple requests for +// the same URL arrive concurrently, they share a single Task rather than creating redundant +// network requests. +// +// The actor ensures thread-safe access to the cache and ongoing tasks dictionary. Each +// request checks the cache first, then ongoing tasks, and only creates a new task if neither +// exists. Tasks are removed from the ongoing dictionary via defer after completion or failure. + +actor ImageLoader { + static let shared = ImageLoader() + + private let cache: NSCache> + private let data: (URL) async throws -> (Data, URLResponse) + private var ongoingTasks: [URL: Task] = [:] + + init(session: URLSession = URLSession(configuration: .imageLoading)) { + self.init(cache: NSCache(), data: session.data(from:)) + } + + init( + cache: @autoclosure @Sendable () -> NSCache>, + data: @escaping (URL) async throws -> (Data, URLResponse) + ) { + self.cache = cache() + self.data = data + } + + func image(for url: URL) async throws -> Image { + // Check for a cached image + if let image = self.cache.object(forKey: url as NSURL) { + return image.wrappedValue + } + + // Check for an ongoing task + if let task = self.ongoingTasks[url] { + return try await task.value + } + + // Create a task + let task = Task { + defer { + // Remove ongoing task + self.ongoingTasks.removeValue(forKey: url) + } + + let (data, response) = try await self.data(url) + + // Notice that `data` and `file` URL schemes will not return `HTTPURLResponse` + if let httpResponse = response as? HTTPURLResponse { + guard 200..<300 ~= httpResponse.statusCode else { + throw URLError(.badServerResponse) + } + } + + guard let image = Image(data: data) else { + throw URLError(.cannotDecodeContentData) + } + + // Cache image + self.cache.setObject(Box(image), forKey: url as NSURL) + + return image + } + + // Add ongoing task + self.ongoingTasks[url] = task + + return try await task.value + } +} + +extension URLSessionConfiguration { + fileprivate static var imageLoading: URLSessionConfiguration { + let configuration = Self.default + + configuration.requestCachePolicy = .returnCacheDataElseLoad + configuration.timeoutIntervalForRequest /= 2 + configuration.httpAdditionalHeaders = ["Accept": "image/*"] + + return configuration + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Image/ImageView.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Image/ImageView.swift new file mode 100644 index 000000000..a9d1bebbf --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/Image/ImageView.swift @@ -0,0 +1,230 @@ +import SwiftUI + +struct ImageView: View { + private let content: Image + + init(_ content: Image) { + self.content = content + } + + var body: some View { + if content.isAnimated { + AnimatedImageView(content) + } else { + SwiftUI.Image(decorative: content.cgImage, scale: 1.0) + .resizable() + } + } +} + +// MARK: - AnimatedImageView + +private struct AnimatedImageView: View { + @State private var clock: AnimationClock + + private let content: Image + + init(_ content: Image) { + self._clock = State(initialValue: .init(image: content)) + self.content = content + } + + var body: some View { + TimelineView(.animation) { context in + let index = clock.frameIndex(at: context.date) + let frame = content.frames[index].cgImage + + SwiftUI.Image(decorative: frame, scale: 1.0) + .resizable() + } + .onChange(of: content) { _, newValue in + clock = AnimationClock(image: newValue) + } + } +} + +// MARK: - AnimationClock +// +// AnimationClock maps elapsed time to frame indices. The Schedule pre-computes cumulative +// frame start times and uses binary search for efficient frame lookup. +// +// loopCount semantics: 0 means infinite looping, >0 means loop that many times then freeze +// on the last frame. + +private struct AnimationClock: Sendable { + private let referenceDate: Date + private let schedule: Schedule + private let loopCount: Int + + init(image: Image, referenceDate: Date = .init()) { + self.referenceDate = referenceDate + self.schedule = Schedule(delayTimes: image.frames.map(\.delayTime)) + self.loopCount = image.loopCount + } + + func frameIndex(at date: Date) -> Int { + guard schedule.numberOfFrames > 0 else { return 0 } + + let elapsedSinceStart = max(0, date.timeIntervalSince(referenceDate)) + + if loopCount > 0 { + let totalAnimationDuration = schedule.cycleDuration * TimeInterval(loopCount) + + if elapsedSinceStart >= totalAnimationDuration { + // freeze on last frame + return schedule.numberOfFrames - 1 + } + } + + let elapsedWithinCycle = elapsedSinceStart.truncatingRemainder( + dividingBy: schedule.cycleDuration + ) + return schedule.frameIndex(forElapsedTime: elapsedWithinCycle) + } +} + +extension AnimationClock { + private struct Schedule: Sendable { + var numberOfFrames: Int { + max(0, startTimes.count - 1) + } + + let cycleDuration: TimeInterval + + private let startTimes: [TimeInterval] + + init(delayTimes: [TimeInterval]) { + var startTimes: [TimeInterval] = [0] + startTimes.reserveCapacity(delayTimes.count + 1) + + var accumulatedTime: TimeInterval = 0 + for delayTime in delayTimes { + accumulatedTime += max(0, delayTime) + startTimes.append(accumulatedTime) + } + + self.startTimes = startTimes + self.cycleDuration = max(accumulatedTime, .leastNonzeroMagnitude) + } + + func frameIndex(forElapsedTime elapsedTime: TimeInterval) -> Int { + var lowerBound = 0 + var upperBound = startTimes.count - 1 + while lowerBound < upperBound { + let candidate = (lowerBound + upperBound + 1) / 2 + if startTimes[candidate] <= elapsedTime { + lowerBound = candidate + } else { + upperBound = candidate - 1 + } + } + return min(lowerBound, numberOfFrames - 1) + } + } +} + +// MARK: - Preview + +#Preview("JPEG") { + @Previewable @State var image: Image? + + Group { + if let image { + ImageView(image) + .aspectRatio(contentMode: .fit) + .frame(width: image.size.width) + } else { + Color.clear + } + } + .task { + image = try? await ImageLoader.shared.image( + for: URL(string: "https://picsum.photos/id/91/400/300")! + ) + } +} + +#Preview("GIF") { + @Previewable @State var image: Image? + + Group { + if let image { + ImageView(image) + .aspectRatio(contentMode: .fit) + .frame(width: image.size.width) + } else { + Color.clear + } + } + .task { + image = try? await ImageLoader.shared.image( + for: URL( + string: + "https://user-images.githubusercontent.com/373190/209442987-2aa9d73d-3bf2-46cb-b03a-5d9c0ab8475f.gif" + )! + ) + } +} + +#Preview("APNG") { + @Previewable @State var image: Image? + + Group { + if let image { + ImageView(image) + .aspectRatio(contentMode: .fit) + .frame(width: image.size.width) + } else { + Color.clear + } + } + .task { + image = try? await ImageLoader.shared.image( + for: URL( + string: + "https://s3.masto.ai/custom_emojis/images/000/014/970/original/807d7387e4b3bf5f.png" + )! + ) + } +} + +#Preview("webP") { + @Previewable @State var image: Image? + + Group { + if let image { + ImageView(image) + .aspectRatio(contentMode: .fit) + .frame(width: image.size.width) + } else { + Color.clear + } + } + .task { + image = try? await ImageLoader.shared.image( + for: URL(string: "https://mathiasbynens.be/demo/animated-webp-supported.webp")! + ) + } +} + +#Preview("HEIC") { + @Previewable @State var image: Image? + + Group { + if let image { + ImageView(image) + .aspectRatio(contentMode: .fit) + .frame(width: image.size.width) + } else { + Color.clear + } + } + .task { + image = try? await ImageLoader.shared.image( + for: URL( + string: + "https://nokiatech.github.io/heif/content/image_sequences/starfield_animation.heic" + )! + ) + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/InlineText/WithInlineStyle.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/InlineText/WithInlineStyle.swift new file mode 100644 index 000000000..bce84cb7e --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/InlineText/WithInlineStyle.swift @@ -0,0 +1,83 @@ +import SwiftUI + +// MARK: - Overview +// +// `WithInlineStyle` applies an `InlineStyle` to an `AttributedString` before it reaches the +// rendering pipeline. +// +// The input `AttributedString` is expected to carry inline semantics using standard Foundation +// attributes: +// - `inlinePresentationIntent` identifies spans like code, emphasis, strong, and strikethrough. +// - `link` identifies URLs. +// +// The view reads `InlineStyle` and `TextEnvironmentValues` from the environment, then produces a +// styled copy of the attributed string by merging attributes into each matching span. +// +// Styling is recomputed whenever the input, style, or environment snapshot changes. + +struct WithInlineStyle: View { + @Environment(\.inlineStyle) private var style + @Environment(\.textEnvironment) private var environment + + @State private var output: AttributedString? + + private let input: AttributedString + private let content: (AttributedString) -> Content + + init( + _ input: AttributedString, + @ViewBuilder content: @escaping (AttributedString) -> Content + ) { + self.input = input + self.content = content + } + + var body: some View { + content(output ?? AttributedString()) + .onChange(of: Tuple(input, style, environment), initial: true) { _, newValue in + resolve( + attributedString: newValue.values.0, + style: newValue.values.1, + in: newValue.values.2 + ) + } + } + + private func resolve( + attributedString: AttributedString, + style: InlineStyle, + in environment: TextEnvironmentValues + ) { + var output = attributedString + + for run in attributedString.runs { + var attributes = AttributeContainer() + + if let intent = run.inlinePresentationIntent { + if intent.contains(.code) { + style.code.apply(in: &attributes, environment: environment) + } + + if intent.contains(.emphasized) { + style.emphasis.apply(in: &attributes, environment: environment) + } + + if intent.contains(.stronglyEmphasized) { + style.strong.apply(in: &attributes, environment: environment) + } + + if intent.contains(.strikethrough) { + style.strikethrough.apply(in: &attributes, environment: environment) + } + } + + if run.link != nil { + style.link.apply(in: &attributes, environment: environment) + } + + output[run.range].mergeAttributes(attributes, mergePolicy: .keepNew) + } + + self.output = output + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/MarkdownParser/Emoji+Preview.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/MarkdownParser/Emoji+Preview.swift new file mode 100644 index 000000000..5b2eb0db5 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/MarkdownParser/Emoji+Preview.swift @@ -0,0 +1,30 @@ +import Foundation + +extension Set where Element == Emoji { + static let previewEmoji: Self = [ + Emoji( + shortcode: "doge", + url: URL( + string: "https://s3.masto.ai/custom_emojis/images/000/009/662/original/13cbe67b559a0b17.png" + )! + ), + Emoji( + shortcode: "dogroll", + url: URL( + string: "https://s3.masto.ai/custom_emojis/images/000/015/362/original/ef3d8a02071a8817.gif" + )! + ), + Emoji( + shortcode: "confused_dog", + url: URL( + string: "https://s3.masto.ai/custom_emojis/images/000/015/374/original/f2f9f6f06168baca.gif" + )! + ), + Emoji( + shortcode: "sad_dog", + url: URL( + string: "https://s3.masto.ai/custom_emojis/images/000/015/592/original/d1d6d0bac8777ac4.png" + )! + ), + ] +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/MarkdownParser/PatternProcessor.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/MarkdownParser/PatternProcessor.swift new file mode 100644 index 000000000..3aa7c40bf --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/MarkdownParser/PatternProcessor.swift @@ -0,0 +1,107 @@ +import Foundation + +// MARK: - Overview +// +// `PatternProcessor` applies pattern-based substitutions to an `AttributedString` after parsing. +// It walks each run, skips preformatted content, tokenizes the run’s text, and replaces tokens +// using the first matching syntax extension. +// +// The processor keeps run attributes intact for unchanged text and allows replacement logic to +// inject new attributes (for example, emoji URLs) while preserving the rest of the run’s metadata. +// +// Syntax extensions are opt-in; when no extensions are provided, the input is returned unchanged. + +extension AttributedStringMarkdownParser { + struct PatternProcessor { + private let syntaxExtensions: [SyntaxExtension] + private let tokenizer: PatternTokenizer + + init(syntaxExtensions: [SyntaxExtension]) { + self.syntaxExtensions = syntaxExtensions + self.tokenizer = PatternTokenizer(patterns: syntaxExtensions.flatMap(\.patterns)) + } + + func expand(_ attributedString: AttributedString) throws -> AttributedString { + guard !syntaxExtensions.isEmpty else { + return attributedString + } + + var output = AttributedString() + + for run in attributedString.runs { + if run.isPreformatted { + output.append(attributedString[run.range]) + } else { + let text = String(attributedString[run.range].characters[...]) + let tokens = try tokenizer.tokenize(text) + + if tokens.count == 1, tokens.first?.type == .text { + // There are no patterns detected + output.append(attributedString[run.range]) + } else { + for token in tokens { + if let syntaxExtension = syntaxExtensions.firstMatching(token.type), + let replacement = syntaxExtension.replace(token, run.attributes) + { + output.append(replacement) + } else { + // Append the token content without replacing + output.append(AttributedString(token.content, attributes: run.attributes)) + } + } + } + } + } + + return output + } + } +} + +extension Array where Element == AttributedStringMarkdownParser.SyntaxExtension { + func firstMatching(_ tokenType: PatternTokenizer.TokenType) -> Element? { + guard tokenType != .text else { + return nil + } + return first { + $0.patterns.map(\.tokenType).contains(tokenType) + } + } +} + +extension AttributedString.Runs.Run { + fileprivate var isPreformatted: Bool { + if self.inlinePresentationIntent?.isPreformatted ?? false { + return true + } + + if self.presentationIntent?.isPreformatted ?? false { + return true + } + + return false + } +} + +extension InlinePresentationIntent { + fileprivate var isPreformatted: Bool { + contains(.code) || contains(.inlineHTML) || contains(.blockHTML) + } +} + +extension PresentationIntent { + fileprivate var isPreformatted: Bool { + components.first?.kind.isPreformatted ?? false + } +} + +extension PresentationIntent.Kind { + fileprivate var isPreformatted: Bool { + switch self { + case .codeBlock: + return true + default: + return false + } + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/MarkdownParser/PatternTokenizer.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/MarkdownParser/PatternTokenizer.swift new file mode 100644 index 000000000..56fc1d8b5 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/MarkdownParser/PatternTokenizer.swift @@ -0,0 +1,124 @@ +import Foundation + +// MARK: - Overview +// +// `PatternTokenizer` scans source text and splits it into tokens based on a small set of +// regex patterns. +// +// It’s designed for postprocessing steps that need to rewrite specific constructs (like emoji +// shortcodes) while leaving everything else untouched. Each pattern is applied as a prefix match +// at the current cursor position, which keeps the tokenizer simple and predictable. +// +// This tokenizer is intentionally conservative: patterns are opt-in and processing is linear. If +// no patterns are provided, the input is returned as a single `.text` token. + +struct PatternTokenizer { + private let patterns: [Pattern] + + init(patterns: [Pattern]) { + self.patterns = patterns + } + + func tokenize(_ input: String) throws -> [Token] { + guard !patterns.isEmpty else { + return [.init(type: .text, content: input)] + } + + var tokens: [Token] = [] + var currentIndex = input.startIndex + + while currentIndex < input.endIndex { + var matchFound = false + + // Try each pattern at the current position + for pattern in patterns { + guard let match = try pattern.regex.prefixMatch(in: input[currentIndex...]) else { + continue + } + + // Add any text before the match + if currentIndex < match.range.lowerBound { + let markup = String(input[currentIndex.. + let tokenType: TokenType + } +} + +extension PatternTokenizer.Pattern { + static var emoji: Self { + .init(regex: /:([a-zA-Z0-9_+-]+):/, tokenType: .emoji) + } + + static var mathBlock: Self { + .init(regex: /(?s)\$\$(.+?)\$\$/, tokenType: .mathBlock) + } + + static var mathInline: Self { + .init(regex: /\$(?!\$)((?:\\\$|[^$\n])+)\$/, tokenType: .mathInline) + } +} + +extension PatternTokenizer { + struct Token: Hashable, Sendable { + let type: TokenType + var content: String + var capturedContent: String? + } +} + +extension PatternTokenizer { + struct TokenType: Hashable, RawRepresentable, Sendable, ExpressibleByStringLiteral { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: StringLiteralType) { + self.rawValue = value + } + } +} + +extension PatternTokenizer.TokenType { + static let text: Self = "text" + static let emoji: Self = "emoji" + static let mathBlock: Self = "mathBlock" + static let mathInline: Self = "mathInline" +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/BlockContent.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/BlockContent.swift new file mode 100644 index 000000000..fdff606fe --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/BlockContent.swift @@ -0,0 +1,63 @@ +import SwiftUI + +extension StructuredText { + struct BlockContent: View { + private let parent: PresentationIntent.IntentType? + private let content: Content + + init(parent: PresentationIntent.IntentType? = nil, content: Content) { + self.parent = parent + self.content = content + } + + var body: some View { + let runs = content.blockRuns(parent: parent) + + BlockVStack { + ForEach(runs.indices, id: \.self) { index in + let run = runs[index] + Block(intent: run.intent, content: content[run.range]) + } + } + } + } +} + +extension StructuredText { + struct Block: View { + private let intent: PresentationIntent.IntentType? + private let content: AttributedSubstring + + init(intent: PresentationIntent.IntentType?, content: AttributedSubstring) { + self.intent = intent + self.content = content + } + + var body: some View { + switch intent?.kind { + case .paragraph where content.isMathBlock: + MathBlock(content) + case .paragraph: + Paragraph(content) + case .header(let level): + Heading(content, level: level) + case .orderedList: + OrderedList(intent: intent, content: content) + case .unorderedList: + UnorderedList(intent: intent, content: content) + case .codeBlock(let languageHint) where languageHint?.lowercased() == "math": + MathCodeBlock(content) + case .codeBlock(let languageHint): + CodeBlock(content, languageHint: languageHint) + case .blockQuote: + BlockQuote(intent: intent, content: content) + case .thematicBreak: + ThematicBreak(content) + case .table(let columns): + Table(intent: intent, content: content, columns: columns) + default: + Paragraph(content) + } + } + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/BlockQuote.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/BlockQuote.swift new file mode 100644 index 000000000..5362b3cf1 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/BlockQuote.swift @@ -0,0 +1,34 @@ +import SwiftUI + +extension StructuredText { + struct BlockQuote: View { + @Environment(\.blockQuoteStyle) private var blockQuoteStyle + + private let intent: PresentationIntent.IntentType? + private let content: AttributedSubstring + + init(intent: PresentationIntent.IntentType?, content: AttributedSubstring) { + self.intent = intent + self.content = content + } + + var body: some View { + let configuration = BlockStyleConfiguration( + label: .init( + BlockContent( + parent: intent, + content: content + ) + ), + indentationLevel: indentationLevel + ) + let resolvedStyle = blockQuoteStyle.resolve(configuration: configuration) + + AnyView(resolvedStyle) + } + + private var indentationLevel: Int { + content.runs.first?.presentationIntent?.indentationLevel ?? 0 + } + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/BlockSpacingKey.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/BlockSpacingKey.swift new file mode 100644 index 000000000..173cd06ed --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/BlockSpacingKey.swift @@ -0,0 +1,11 @@ +import SwiftUI + +extension StructuredText { + struct BlockSpacingKey: PreferenceKey, LayoutValueKey { + static let defaultValue = BlockSpacing() + + static func reduce(value: inout BlockSpacing, nextValue: () -> BlockSpacing) { + value = value.union(nextValue()) + } + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/BlockVStack.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/BlockVStack.swift new file mode 100644 index 000000000..fa6801261 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/BlockVStack.swift @@ -0,0 +1,192 @@ +import SwiftUI + +// MARK: - Overview +// +// BlockVStack arranges blocks vertically with CSS-style margin collapsing behavior. Adjacent +// blocks' top and bottom spacing collapses by taking the maximum value rather than summing, +// matching how CSS margins work. +// +// List items can override block spacing with environment-driven list item spacing for consistent +// spacing within lists regardless of individual block preferences. + +extension StructuredText { + struct BlockVStack: View { + @Environment(\.multilineTextAlignment) private var textAlignment + + private let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + Group(subviews: content) { children in + BlockVStackLayout(textAlignment: textAlignment) { + ForEach(children) { + BlockLayoutView($0) + } + } + } + } + } +} + +extension StructuredText { + struct BlockAlignmentKey: LayoutValueKey { + static let defaultValue: TextAlignment? = nil + } + + fileprivate struct BlockLayoutView: View { + @Environment(\.listItemSpacingEnabled) private var listItemSpacingEnabled + @Environment(\.resolvedListItemSpacing) private var resolvedListItemSpacing + + @State private var blockSpacing = BlockSpacing() + + private let content: Content + + init(_ content: Content) { + self.content = content + } + + var body: some View { + // Read the block spacing preference and apply it as a layout value + content + .onPreferenceChange(BlockSpacingKey.self) { @MainActor value in + // Override with the resolved list item spacing if enabled + blockSpacing = listItemSpacingEnabled ? resolvedListItemSpacing : value + } + .layoutValue(key: BlockSpacingKey.self, value: blockSpacing) + } + } + + fileprivate struct BlockVStackLayout: Layout { + struct Cache { + let spacings: [CGFloat] + } + + let textAlignment: TextAlignment + + func makeCache(subviews: Subviews) -> Cache { + return Cache( + spacings: subviews.indices.dropLast().map { index in + let current = subviews[index] + let next = subviews[index + 1] + let currentBottom = current[BlockSpacingKey.self].bottom + let nextTop = next[BlockSpacingKey.self].top + + // Take the maximum block spacing, otherwise the preferred view spacing + return [currentBottom, nextTop].compactMap(\.self).max() + ?? current.spacing.distance(to: next.spacing, along: .vertical) + } + ) + } + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, cache: inout Cache + ) -> CGSize { + if let width = proposal.width, width <= 0 { + return .zero + } + + var size = CGSize.zero + + for view in subviews { + let viewSize = view.sizeThatFits(.init(width: proposal.width, height: nil)) + size.height += viewSize.height + size.width = max(size.width, viewSize.width) + } + + size.height += cache.spacings.reduce(0, +) + + return size + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, cache: inout Cache + ) { + var currentY: CGFloat = 0 + + for (index, view) in zip(subviews.indices, subviews) { + let viewProposal = ProposedViewSize(width: proposal.width, height: nil) + let viewSize = view.sizeThatFits(viewProposal) + + var point = bounds.origin + let alignment = view[BlockAlignmentKey.self] ?? textAlignment + + switch alignment { + case .leading: + break // do nothing + case .center: + point.x += (bounds.width - viewSize.width) / 2 + case .trailing: + point.x += bounds.width - viewSize.width + } + + point.y += currentY + + view.place(at: point, proposal: viewProposal) + + currentY += viewSize.height + + if index < subviews.count - 1 { + currentY += cache.spacings[index] + } + } + } + } +} + +@available(tvOS, unavailable) +@available(watchOS, unavailable) +#Preview { + @Previewable @State var textAlignment = TextAlignment.leading + @Previewable @State var blockSpacing: CGFloat = 1 + + VStack { + GroupBox { + Picker("Text Alignment", selection: $textAlignment) { + Text("Leading").tag(TextAlignment.leading) + Text("Center").tag(TextAlignment.center) + Text("Trailing").tag(TextAlignment.trailing) + } + .pickerStyle(.segmented) + HStack { + Text("2nd / 3rd Spacing") + Slider(value: $blockSpacing, in: 0...3) + } + } + Spacer() + StructuredText.BlockVStack { + Text( + """ + Listen to your sister, Morty. To live is to risk it all, otherwise you’re just an inert \ + chunk of randomly assembled molecules drifting wherever the universe blows you. + """ + ) + .textual.blockSpacing(.fontScaled(bottom: 1)) + Text( + """ + Listen, Morty, I hate to break it to you but what people call "love" is just a chemical \ + reaction that compels animals to breed. It hits hard, Morty, then it slowly fades, \ + leaving you stranded in a failing marriage. I did it. Your parents are gonna do it. \ + Break the cycle, Morty. Rise above. Focus on science. + """ + ) + .textual.blockSpacing(.fontScaled(bottom: blockSpacing)) + Text( + """ + Wow, I really Cronenberged up the whole place, huh Morty? Just a bunch a Cronenbergs \ + walkin' around. + """ + ) + .textual.blockSpacing(.fontScaled(top: 1, bottom: 1)) + } + .border(Color.red) + Spacer() + } + .multilineTextAlignment(textAlignment) + .padding() +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/CodeBlock.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/CodeBlock.swift new file mode 100644 index 000000000..002d844e5 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/CodeBlock.swift @@ -0,0 +1,43 @@ +import SwiftUI + +extension StructuredText { + struct CodeBlock: View { + @Environment(\.highlighterTheme) private var highlighterTheme + @Environment(\.codeBlockStyle) private var codeBlockStyle + + private let content: AttributedSubstring + private let languageHint: String? + + init(_ content: AttributedSubstring, languageHint: String?) { + if let last = content.characters.indices.last, content.characters[last] == "\n" { + self.content = content[.. = .fontScaled(top: 0.25) + @Entry var resolvedListItemSpacing: StructuredText.BlockSpacing = .init() + @Entry var listItemSpacingEnabled: Bool = false +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/MathBlock.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/MathBlock.swift new file mode 100644 index 000000000..0c941af4a --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/MathBlock.swift @@ -0,0 +1,35 @@ +import SwiftUI + +extension StructuredText { + struct MathBlock: View { + @Environment(\.paragraphStyle) private var paragraphStyle + @Environment(\.mathProperties) private var mathProperties + + private let content: AttributedSubstring + + init(_ content: AttributedSubstring) { + self.content = content + } + + var body: some View { + let configuration = BlockStyleConfiguration( + label: .init(label), + indentationLevel: indentationLevel + ) + let resolvedStyle = paragraphStyle.resolve(configuration: configuration) + + AnyView(resolvedStyle) + .layoutValue(key: BlockAlignmentKey.self, value: mathProperties.textAlignment) + } + + private var label: some View { + WithInlineStyle(AttributedString(content)) { + TextFragment($0) + } + } + + private var indentationLevel: Int { + content.presentationIntent?.indentationLevel ?? 0 + } + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/MathCodeBlock.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/MathCodeBlock.swift new file mode 100644 index 000000000..9c5bfa9cd --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/MathCodeBlock.swift @@ -0,0 +1,39 @@ +import SwiftUI + +extension StructuredText { + struct MathCodeBlock: View { + @Environment(\.paragraphStyle) private var paragraphStyle + @Environment(\.mathProperties) private var mathProperties + + private let content: AttributedString + private let indentationLevel: Int + + init(_ content: AttributedSubstring) { + let latex = String(content.characters[...]) + .trimmingCharacters(in: .whitespacesAndNewlines) + let attachment = MathAttachment(latex: latex, style: .block) + self.content = AttributedString( + "\u{FFFC}", + attributes: AttributeContainer().attachment(.init(attachment)) + ) + self.indentationLevel = content.presentationIntent?.indentationLevel ?? 0 + } + + var body: some View { + let configuration = BlockStyleConfiguration( + label: .init(label), + indentationLevel: indentationLevel + ) + let resolvedStyle = paragraphStyle.resolve(configuration: configuration) + + AnyView(resolvedStyle) + .layoutValue(key: BlockAlignmentKey.self, value: mathProperties.textAlignment) + } + + private var label: some View { + WithInlineStyle(content) { + TextFragment($0) + } + } + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/OrderedList.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/OrderedList.swift new file mode 100644 index 000000000..720b6877b --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/OrderedList.swift @@ -0,0 +1,123 @@ +import SwiftUI + +// MARK: - Overview +// +// OrderedList aligns all list item markers to a consistent width for uniform layout. Each marker +// measures itself and reports its width via preferences. The maximum width is taken and applied +// to all markers, ensuring alignment even when marker widths vary (e.g., "1." vs "10."). + +extension StructuredText { + struct OrderedList: View { + @Environment(\.listItemSpacing) private var listItemSpacing + @Environment(\.textEnvironment) private var textEnvironment + + @State private var markerWidth: CGFloat? + + private let intent: PresentationIntent.IntentType? + private let content: AttributedSubstring + + init(intent: PresentationIntent.IntentType?, content: AttributedSubstring) { + self.intent = intent + self.content = content + } + + var body: some View { + let runs = content.blockRuns(parent: intent) + + BlockVStack { + ForEach(runs.indices, id: \.self) { index in + let run = runs[index] + + OrderedListItem( + intent: run.intent, + content: content[run.range], + markerWidth: markerWidth + ) + } + } + .onPreferenceChange(MarkerWidthKey.self) { @MainActor in + markerWidth = $0 + } + .environment(\.resolvedListItemSpacing, listItemSpacing.resolve(in: textEnvironment)) + .environment(\.listItemSpacingEnabled, true) + } + } +} + +extension StructuredText { + fileprivate struct OrderedListItem: View { + @Environment(\.listItemStyle) private var listItemStyle + @Environment(\.orderedListMarker) private var orderedListMarker + + private let intent: PresentationIntent.IntentType? + private let content: AttributedSubstring + private let markerWidth: CGFloat? + + init( + intent: PresentationIntent.IntentType?, + content: AttributedSubstring, + markerWidth: CGFloat? + ) { + self.intent = intent + self.content = content + self.markerWidth = markerWidth + } + + var body: some View { + let configuration = ListItemStyleConfiguration( + marker: .init(marker), + block: .init( + BlockContent( + parent: intent, + content: content + ) + ), + indentationLevel: indentationLevel + ) + let resolvedStyle = listItemStyle.resolve(configuration: configuration) + + AnyView(resolvedStyle) + } + + private var marker: some View { + AnyView( + orderedListMarker.resolve( + configuration: .init( + indentationLevel: indentationLevel, + ordinal: ordinal + ) + ) + ) + .background { + // Propagate the marker width + GeometryReader { proxy in + Color.clear + .preference( + key: MarkerWidthKey.self, + value: proxy.size.width + ) + } + } + .frame(width: markerWidth, alignment: .trailing) + } + + private var indentationLevel: Int { + content.runs.first?.presentationIntent?.indentationLevel ?? 0 + } + + private var ordinal: Int { + guard case .listItem(let ordinal) = intent?.kind else { + return 0 + } + return ordinal + } + } + + fileprivate struct MarkerWidthKey: PreferenceKey { + static let defaultValue: CGFloat? = nil + + static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) { + value = [value, nextValue()].compactMap(\.self).max() + } + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Paragraph.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Paragraph.swift new file mode 100644 index 000000000..b61f4dc2d --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Paragraph.swift @@ -0,0 +1,33 @@ +import SwiftUI + +extension StructuredText { + struct Paragraph: View { + @Environment(\.paragraphStyle) private var paragraphStyle + + private let content: AttributedSubstring + + init(_ content: AttributedSubstring) { + self.content = content + } + + var body: some View { + let configuration = BlockStyleConfiguration( + label: .init(label), + indentationLevel: indentationLevel + ) + let resolvedStyle = paragraphStyle.resolve(configuration: configuration) + + AnyView(resolvedStyle) + } + + private var label: some View { + WithInlineStyle(AttributedString(content)) { + TextFragment($0) + } + } + + private var indentationLevel: Int { + content.presentationIntent?.indentationLevel ?? 0 + } + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Style/OverflowFrameKey.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Style/OverflowFrameKey.swift new file mode 100644 index 000000000..95f635d25 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Style/OverflowFrameKey.swift @@ -0,0 +1,17 @@ +import SwiftUI + +// MARK: - Overview +// +// OverflowFrameKey collects frame rectangles from scrollable overflow regions (like code blocks) +// and propagates them via preferences to selection overlays. +// +// Text selection uses these frames to exclude scrollable regions from hit-testing so embedded +// ScrollViews can receive touch events while the parent selection interaction ignores them. + +struct OverflowFrameKey: PreferenceKey { + static let defaultValue: [CGRect] = [] + + static func reduce(value: inout [CGRect], nextValue: () -> [CGRect]) { + value.append(contentsOf: nextValue()) + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Style/ResolvedBlockQuoteStyle.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Style/ResolvedBlockQuoteStyle.swift new file mode 100644 index 000000000..f8f4dd8b6 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Style/ResolvedBlockQuoteStyle.swift @@ -0,0 +1,24 @@ +import SwiftUI + +extension StructuredText { + // NB: Enables environment resolution in `BlockQuoteStyle` + struct ResolvedBlockQuoteStyle: View { + private let style: S + private let configuration: S.Configuration + + init(_ style: S, configuration: S.Configuration) { + self.style = style + self.configuration = configuration + } + + var body: S.Body { + style.makeBody(configuration: configuration) + } + } +} + +extension StructuredText.BlockQuoteStyle { + @MainActor func resolve(configuration: Configuration) -> some View { + StructuredText.ResolvedBlockQuoteStyle(self, configuration: configuration) + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Style/ResolvedCodeBlockStyle.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Style/ResolvedCodeBlockStyle.swift new file mode 100644 index 000000000..258491162 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Style/ResolvedCodeBlockStyle.swift @@ -0,0 +1,24 @@ +import SwiftUI + +extension StructuredText { + // NB: Enables environment resolution in `CodeBlockStyle` + struct ResolvedCodeBlockStyle: View { + private let style: S + private let configuration: S.Configuration + + init(_ style: S, configuration: S.Configuration) { + self.style = style + self.configuration = configuration + } + + var body: S.Body { + style.makeBody(configuration: configuration) + } + } +} + +extension StructuredText.CodeBlockStyle { + @MainActor func resolve(configuration: Configuration) -> some View { + StructuredText.ResolvedCodeBlockStyle(self, configuration: configuration) + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Style/ResolvedHeadingStyle.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Style/ResolvedHeadingStyle.swift new file mode 100644 index 000000000..bbdcf76ef --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Style/ResolvedHeadingStyle.swift @@ -0,0 +1,24 @@ +import SwiftUI + +extension StructuredText { + // NB: Enables environment resolution in `HeadingStyle` + struct ResolvedHeadingStyle: View { + private let style: S + private let configuration: S.Configuration + + init(_ style: S, configuration: S.Configuration) { + self.style = style + self.configuration = configuration + } + + var body: S.Body { + style.makeBody(configuration: configuration) + } + } +} + +extension StructuredText.HeadingStyle { + @MainActor func resolve(configuration: Configuration) -> some View { + StructuredText.ResolvedHeadingStyle(self, configuration: configuration) + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Style/ResolvedListItemStyle.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Style/ResolvedListItemStyle.swift new file mode 100644 index 000000000..7e21fbb31 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Style/ResolvedListItemStyle.swift @@ -0,0 +1,24 @@ +import SwiftUI + +extension StructuredText { + // NB: Enables environment resolution in `ListItemStyle` + struct ResolvedListItemStyle: View { + private let style: S + private let configuration: S.Configuration + + init(_ style: S, configuration: S.Configuration) { + self.style = style + self.configuration = configuration + } + + var body: S.Body { + style.makeBody(configuration: configuration) + } + } +} + +extension StructuredText.ListItemStyle { + @MainActor func resolve(configuration: Configuration) -> some View { + StructuredText.ResolvedListItemStyle(self, configuration: configuration) + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Style/ResolvedOrderedListMarker.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Style/ResolvedOrderedListMarker.swift new file mode 100644 index 000000000..6152c6cd0 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Style/ResolvedOrderedListMarker.swift @@ -0,0 +1,24 @@ +import SwiftUI + +extension StructuredText { + // NB: Enables environment resolution in `OrderedListMarker` + struct ResolvedOrderedListMarker: View { + private let marker: M + private let configuration: M.Configuration + + init(_ marker: M, configuration: M.Configuration) { + self.marker = marker + self.configuration = configuration + } + + var body: M.Body { + marker.makeBody(configuration: configuration) + } + } +} + +extension StructuredText.OrderedListMarker { + @MainActor func resolve(configuration: Configuration) -> some View { + StructuredText.ResolvedOrderedListMarker(self, configuration: configuration) + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Style/ResolvedParagraphStyle.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Style/ResolvedParagraphStyle.swift new file mode 100644 index 000000000..7e5ac4d97 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Style/ResolvedParagraphStyle.swift @@ -0,0 +1,24 @@ +import SwiftUI + +extension StructuredText { + // NB: Enables environment resolution in `ParagraphStyle` + struct ResolvedParagraphStyle: View { + private let style: S + private let configuration: S.Configuration + + init(_ style: S, configuration: S.Configuration) { + self.style = style + self.configuration = configuration + } + + var body: S.Body { + style.makeBody(configuration: configuration) + } + } +} + +extension StructuredText.ParagraphStyle { + @MainActor func resolve(configuration: Configuration) -> some View { + StructuredText.ResolvedParagraphStyle(self, configuration: configuration) + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Style/ResolvedTableCellStyle.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Style/ResolvedTableCellStyle.swift new file mode 100644 index 000000000..e383c356f --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Style/ResolvedTableCellStyle.swift @@ -0,0 +1,24 @@ +import SwiftUI + +extension StructuredText { + // NB: Enables environment resolution in `TableCellStyle` + struct ResolvedTableCellStyle: View { + private let style: S + private let configuration: S.Configuration + + init(_ style: S, configuration: S.Configuration) { + self.style = style + self.configuration = configuration + } + + var body: S.Body { + style.makeBody(configuration: configuration) + } + } +} + +extension StructuredText.TableCellStyle { + @MainActor func resolve(configuration: Configuration) -> some View { + StructuredText.ResolvedTableCellStyle(self, configuration: configuration) + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Style/ResolvedTableStyle.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Style/ResolvedTableStyle.swift new file mode 100644 index 000000000..2ca2c8306 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Style/ResolvedTableStyle.swift @@ -0,0 +1,25 @@ +import SwiftUI + +// NB: Enables environment resolution in `TableStyle` + +extension StructuredText { + struct ResolvedTableStyle: View { + private let style: S + private let configuration: S.Configuration + + init(_ style: S, configuration: S.Configuration) { + self.style = style + self.configuration = configuration + } + + var body: S.Body { + style.makeBody(configuration: configuration) + } + } +} + +extension StructuredText.TableStyle { + @MainActor func resolve(configuration: Configuration) -> some View { + StructuredText.ResolvedTableStyle(self, configuration: configuration) + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Style/ResolvedThematicBreakStyle.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Style/ResolvedThematicBreakStyle.swift new file mode 100644 index 000000000..248019f50 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Style/ResolvedThematicBreakStyle.swift @@ -0,0 +1,24 @@ +import SwiftUI + +extension StructuredText { + // NB: Enables environment resolution in `ThematicBreakStyle` + struct ResolvedThematicBreakStyle: View { + private let style: S + private let configuration: S.Configuration + + init(_ style: S, configuration: S.Configuration) { + self.style = style + self.configuration = configuration + } + + var body: S.Body { + style.makeBody(configuration: configuration) + } + } +} + +extension StructuredText.ThematicBreakStyle { + @MainActor func resolve(configuration: Configuration) -> some View { + StructuredText.ResolvedThematicBreakStyle(self, configuration: configuration) + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Style/ResolvedUnorderedListMarker.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Style/ResolvedUnorderedListMarker.swift new file mode 100644 index 000000000..a2d93c127 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Style/ResolvedUnorderedListMarker.swift @@ -0,0 +1,24 @@ +import SwiftUI + +extension StructuredText { + // NB: Enables environment resolution in `UnorderedListMarker` + struct ResolvedUnorderedListMarker: View { + private let marker: M + private let configuration: M.Configuration + + init(_ marker: M, configuration: M.Configuration) { + self.marker = marker + self.configuration = configuration + } + + var body: M.Body { + marker.makeBody(configuration: configuration) + } + } +} + +extension StructuredText.UnorderedListMarker { + @MainActor func resolve(configuration: Configuration) -> some View { + StructuredText.ResolvedUnorderedListMarker(self, configuration: configuration) + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Table.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Table.swift new file mode 100644 index 000000000..b3fdd8b0c --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/Table.swift @@ -0,0 +1,87 @@ +import SwiftUI + +// MARK: - Overview +// +// Table uses a two-pass layout system. The first pass renders cells which emit their bounds via +// preferences. The second pass collects all cell bounds, transforms them from anchor coordinates +// to geometry coordinates, and builds a `TableLayout` that the style uses to render overlays +// (such as grid lines) and backgrounds with precise cell positions. + +extension StructuredText { + struct Table: View { + @Environment(\.tableStyle) private var tableStyle + + @State private var spacing = TableCell.Spacing() + + private let intent: PresentationIntent.IntentType? + private let content: AttributedSubstring + private let columns: [PresentationIntent.TableColumn] + + init( + intent: PresentationIntent.IntentType?, + content: AttributedSubstring, + columns: [PresentationIntent.TableColumn] + ) { + self.intent = intent + self.content = content + self.columns = columns + } + + var body: some View { + let configuration = TableStyleConfiguration( + label: .init(label), + indentationLevel: indentationLevel + ) + let resolvedStyle = tableStyle.resolve(configuration: configuration) + .onPreferenceChange(TableCell.SpacingKey.self) { @MainActor in + spacing = $0 + } + + AnyView(resolvedStyle) + } + + @ViewBuilder + private var label: some View { + let rowRuns = content.blockRuns(parent: intent) + + Grid(horizontalSpacing: spacing.horizontal, verticalSpacing: spacing.vertical) { + ForEach(rowRuns.indices, id: \.self) { rowIndex in + let rowRun = rowRuns[rowIndex] + let rowContent = content[rowRun.range] + let columnRuns = rowContent.blockRuns(parent: rowRun.intent) + + GridRow { + ForEach(columnRuns.indices, id: \.self) { columnIndex in + let cellRun = columnRuns[columnIndex] + let cellContent = rowContent[cellRun.range] + + TableCell(cellContent, row: rowIndex, column: columnIndex) + .gridColumnAlignment(alignment(for: columnIndex)) + } + } + } + } + } + + private var indentationLevel: Int { + content.runs.first?.presentationIntent?.indentationLevel ?? 0 + } + + private func alignment(for columnIndex: Int) -> HorizontalAlignment { + guard columnIndex < columns.count else { + return .leading + } + + switch columns[columnIndex].alignment { + case .left: + return .leading + case .center: + return .center + case .right: + return .trailing + @unknown default: + return .leading + } + } + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/TableCell.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/TableCell.swift new file mode 100644 index 000000000..6e402f27e --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/TableCell.swift @@ -0,0 +1,80 @@ +import SwiftUI + +extension StructuredText { + struct TableCell: View { + @Environment(\.tableCellStyle) private var tableCellStyle + + private let content: AttributedSubstring + private let identifier: TableCell.Identifier + + init(_ content: AttributedSubstring, row: Int, column: Int) { + self.content = content + self.identifier = .init(row: row, column: column) + } + + var body: some View { + let configuration = TableCellStyleConfiguration( + label: .init(label), + indentationLevel: indentationLevel, + row: identifier.row, + column: identifier.column + ) + let resolvedStyle = + tableCellStyle + .resolve(configuration: configuration) + .anchorPreference(key: BoundsKey.self, value: .bounds) { anchor in + [identifier: anchor] + } + + AnyView(resolvedStyle) + } + + private var label: some View { + WithInlineStyle(AttributedString(content)) { + TextFragment($0) + } + } + + private var indentationLevel: Int { + content.presentationIntent?.indentationLevel ?? 0 + } + } +} + +extension StructuredText.TableCell { + struct Identifier: Hashable { + let row: Int + let column: Int + } + + struct BoundsKey: PreferenceKey { + static let defaultValue: [Identifier: Anchor] = [:] + + static func reduce( + value: inout [Identifier: Anchor], + nextValue: () -> [Identifier: Anchor] + ) { + value.merge(nextValue(), uniquingKeysWith: { $1 }) + } + } +} + +extension StructuredText.TableCell { + struct Spacing: Sendable, Hashable { + let horizontal: CGFloat? + let vertical: CGFloat? + + init(horizontal: CGFloat? = nil, vertical: CGFloat? = nil) { + self.horizontal = horizontal + self.vertical = vertical + } + } + + struct SpacingKey: PreferenceKey { + static let defaultValue = Spacing() + + static func reduce(value: inout Spacing, nextValue: () -> Spacing) { + value = nextValue() + } + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/ThematicBreak.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/ThematicBreak.swift new file mode 100644 index 000000000..ae63707a6 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/ThematicBreak.swift @@ -0,0 +1,33 @@ +import SwiftUI + +extension StructuredText { + struct ThematicBreak: View { + @Environment(\.thematicBreakStyle) private var thematicBreakStyle + + private let content: AttributedSubstring + + init(_ content: AttributedSubstring) { + self.content = content + } + + var body: some View { + let configuration = BlockStyleConfiguration( + label: .init(label), + indentationLevel: indentationLevel + ) + let resolvedStyle = thematicBreakStyle.resolve(configuration: configuration) + + AnyView(resolvedStyle) + } + + private var label: some View { + WithInlineStyle(AttributedString(content)) { + TextFragment($0) + } + } + + private var indentationLevel: Int { + content.presentationIntent?.indentationLevel ?? 0 + } + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/UnorderedList.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/UnorderedList.swift new file mode 100644 index 000000000..d3a546b9e --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/StructuredText/UnorderedList.swift @@ -0,0 +1,81 @@ +import SwiftUI + +extension StructuredText { + struct UnorderedList: View { + @Environment(\.listItemSpacing) private var listItemSpacing + @Environment(\.textEnvironment) private var textEnvironment + + private let intent: PresentationIntent.IntentType? + private let content: AttributedSubstring + + init(intent: PresentationIntent.IntentType?, content: AttributedSubstring) { + self.intent = intent + self.content = content + } + + var body: some View { + let runs = content.blockRuns(parent: intent) + + BlockVStack { + ForEach(runs.indices, id: \.self) { index in + let run = runs[index] + + UnorderedListItem( + intent: run.intent, + content: content[run.range] + ) + } + } + .environment(\.resolvedListItemSpacing, listItemSpacing.resolve(in: textEnvironment)) + .environment(\.listItemSpacingEnabled, true) + } + } +} + +extension StructuredText { + fileprivate struct UnorderedListItem: View { + @Environment(\.listItemStyle) private var listItemStyle + @Environment(\.unorderedListMarker) private var unorderedListMarker + + private let intent: PresentationIntent.IntentType? + private let content: AttributedSubstring + + init( + intent: PresentationIntent.IntentType?, + content: AttributedSubstring + ) { + self.intent = intent + self.content = content + } + + var body: some View { + let configuration = ListItemStyleConfiguration( + marker: .init(marker), + block: .init( + BlockContent( + parent: intent, + content: content + ) + ), + indentationLevel: indentationLevel + ) + let resolvedStyle = listItemStyle.resolve(configuration: configuration) + + AnyView(resolvedStyle) + } + + private var marker: some View { + AnyView( + unorderedListMarker.resolve( + configuration: .init( + indentationLevel: indentationLevel + ) + ) + ) + } + + private var indentationLevel: Int { + content.runs.first?.presentationIntent?.indentationLevel ?? 0 + } + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextFragment/TextBuilder.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextFragment/TextBuilder.swift new file mode 100644 index 000000000..15aef01a7 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextFragment/TextBuilder.swift @@ -0,0 +1,136 @@ +import SwiftUI + +// MARK: - Overview +// +// TextBuilder constructs SwiftUI.Text from attributed content with inline attachments. +// It caches Text values keyed by attachment sizes to avoid unnecessary rebuilds during +// resize. When the container size changes, attachment sizes are recomputed and the cache +// is consulted. If the new sizes hash to the same key, the cached Text is reused. +// +// The cache key is derived from the hash of [AttachmentKey: CGSize]. Since attachment +// sizes often remain constant or repeat during incremental resize (e.g., window resizing), +// this compact key enables effective caching without storing the full proposal or +// attributed string. The cache has a count limit of 10 to prevent unbounded growth. +// +// Runs with attachments are converted to placeholder images sized by the attachment's +// sizeThatFits(_:in:) result. Placeholders are tagged with AttachmentAttribute so overlays +// can identify and render the actual attachment views at the resolved layout positions. + +extension TextFragment { + @MainActor @Observable final class TextBuilder { + var text: Text + + @ObservationIgnored private let content: Content + @ObservationIgnored private let cache: NSCache, Box> + + init(_ content: Content, environment: TextEnvironmentValues) { + let attachmentSizes = content.attachmentSizes(for: .unspecified, in: environment) + + self.text = Text( + attributedString: content, + attachmentSizes: attachmentSizes, + in: environment + ) + self.content = content + self.cache = NSCache() + self.cache.countLimit = 10 + + self.cache.setObject(Box(self.text), forKey: KeyBox(attachmentSizes)) + } + + func sizeChanged(_ size: CGSize, environment: TextEnvironmentValues) { + let attachmentSizes = content.attachmentSizes(for: .init(size), in: environment) + let cacheKey = KeyBox(attachmentSizes) + + if let text = cache.object(forKey: cacheKey) { + self.text = text.wrappedValue + } else { + let text = Text( + attributedString: content, + attachmentSizes: attachmentSizes, + in: environment + ) + cache.setObject(Box(text), forKey: cacheKey) + + self.text = text + } + } + } +} + +extension Text { + fileprivate init( + attributedString: some AttributedStringProtocol, + attachmentSizes: [AttachmentKey: CGSize], + in environment: TextEnvironmentValues + ) { + let textValues = attributedString.runs.map { run in + var text: Text + + var runEnvironment = environment + runEnvironment.font = run.font ?? environment.font + + let key = run.textual.attachment.map { + AttachmentKey(attachment: $0, font: runEnvironment.font) + } + + if let key, let size = attachmentSizes[key] { + // Create placeholder + text = Text(placeholderSize: size) + .baselineOffset(key.attachment.baselineOffset(in: runEnvironment)) + .customAttribute( + AttachmentAttribute( + key.attachment, + presentationIntent: run.presentationIntent + ) + ) + } else { + text = Text(AttributedString(attributedString[run.range])) + } + + // Add link attribute for TextLinkInteraction + if let link = run.link { + text = text.customAttribute(LinkAttribute(link)) + } + + return text + } + + self = textValues.reduce(Text(verbatim: "")) { partialResult, text in + partialResult + text + } + } + + private init(placeholderSize size: CGSize) { + self.init(SwiftUI.Image(size: size) { _ in }) + } +} + +extension AttributedStringProtocol { + fileprivate func attachmentSizes( + for proposal: ProposedViewSize, in environment: TextEnvironmentValues + ) -> [AttachmentKey: CGSize] { + Dictionary( + self.runs.compactMap { run in + guard let attachment = run.textual.attachment else { + return nil + } + var environment = environment + environment.font = run.font ?? environment.font + return ( + AttachmentKey( + attachment: attachment, + font: environment.font + ), + attachment.sizeThatFits(proposal, in: environment) + ) + }, + uniquingKeysWith: { existing, _ in existing } + ) + } +} + +private struct AttachmentKey: Hashable { + let attachment: AnyAttachment + let font: Font? +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextFragment/TextFragment.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextFragment/TextFragment.swift new file mode 100644 index 000000000..62a534f33 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextFragment/TextFragment.swift @@ -0,0 +1,77 @@ +import SwiftUI + +// MARK: - Overview +// +// TextFragment renders attributed content as SwiftUI.Text with support for inline +// attachments, links, and selection. It uses a TextBuilder to construct and cache +// Text values, minimizing rebuilds during resize by keying on attachment sizes. +// +// Attachments are represented as placeholder images tagged with AttachmentAttribute. The +// actual attachment views are rendered in an overlay using the resolved Text.Layout +// geometry. Three modifiers are applied at the fragment level: +// +// - TextSelectionBackground renders selection highlights on macOS +// - AttachmentOverlay draws attachments at their run locations with selection-aware dimming +// - TextLinkInteraction handles tap gestures on links +// +// These overlays use backgroundPreferenceValue and overlayPreferenceValue to access +// Text.Layout and render in fragment-local coordinates. Fragment-level overlays enable +// coordinate space isolation and keep scrollable regions interactive. +// +// An ancestor view must define a named coordinate space (.textContainer) for the text +// container. TextFragment uses onGeometryChange to observe the container size and rebuild +// Text when attachment sizes need to change. +// +// TextFragment is used by InlineText and StructuredText (via BlockContent) to render +// attributed content with inline attachments, links, and selection. + +struct TextFragment: View { + @Environment(\.textEnvironment) private var textEnvironment + @State private var textBuilder: TextBuilder? + + private let content: Content + + init(_ content: Content) { + self.content = content + } + + var body: some View { + text + .customAttribute(TextFragmentAttribute()) + .onGeometryChange(for: CGSize?.self, of: \.textContainerSize) { size in + guard let size, let textBuilder else { return } + textBuilder.sizeChanged(size, environment: textEnvironment) + } + .onChange(of: content, initial: true) { _, newValue in + self.textBuilder = TextBuilder(newValue, environment: textEnvironment) + } + .modifier(TextSelectionBackground()) + .modifier(AttachmentOverlay(attachments: content.attachments())) + .modifier(TextLinkInteraction()) + } + + private var text: Text { + textBuilder?.text ?? Text(verbatim: "") + } +} + +struct TextFragmentAttribute: TextAttribute { +} + +extension Text.Layout { + var isTextFragment: Bool { + first?.first?[TextFragmentAttribute.self] != nil + } +} + +extension CoordinateSpaceProtocol where Self == NamedCoordinateSpace { + static var textContainer: NamedCoordinateSpace { + .named("textContainer") + } +} + +extension GeometryProxy { + fileprivate var textContainerSize: CGSize? { + bounds(of: .textContainer)?.size + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/AppKit/AppKitTextInteractionOverlay.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/AppKit/AppKitTextInteractionOverlay.swift new file mode 100644 index 000000000..d6e8e39d9 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/AppKit/AppKitTextInteractionOverlay.swift @@ -0,0 +1,36 @@ +#if TEXTUAL_ENABLE_TEXT_SELECTION && canImport(AppKit) && !targetEnvironment(macCatalyst) + import SwiftUI + + // MARK: - Overview + // + // `AppKitTextInteractionOverlay` bridges the shared `TextSelectionModel` into an `NSView`. + // + // The overlay reads exclusion rectangles for hit-testing. This allows embedded scrollable regions (like + // code blocks) to receive touch events while the parent handles text selection. The view also + // manages selection gestures, keyboard-driven updates, and context menus while SwiftUI renders + // the text. + + struct AppKitTextInteractionOverlay: NSViewRepresentable { + private let model: TextSelectionModel + private let overflowFrames: [CGRect] + + init(model: TextSelectionModel, overflowFrames: [CGRect]) { + self.model = model + self.overflowFrames = overflowFrames + } + + func makeNSView(context: Context) -> NSTextInteractionView { + NSTextInteractionView( + model: model, + exclusionRects: overflowFrames, + openURL: context.environment.openURL + ) + } + + func updateNSView(_ nsView: NSTextInteractionView, context: Context) { + nsView.model = model + nsView.exclusionRects = overflowFrames + nsView.openURL = context.environment.openURL + } + } +#endif diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/AppKit/AppKitTextSelectionInteraction.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/AppKit/AppKitTextSelectionInteraction.swift new file mode 100644 index 000000000..9af287166 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/AppKit/AppKitTextSelectionInteraction.swift @@ -0,0 +1,58 @@ +#if TEXTUAL_ENABLE_TEXT_SELECTION && canImport(AppKit) && !targetEnvironment(macCatalyst) + import SwiftUI + + // MARK: - Overview + // + // `AppKitTextSelectionInteraction` presents the platform-specific text selection overlay for macOS. + // + // The modifier receives a `TextSelectionModel` and places it in the environment so selection highlights + // and attachment dimming can access it. An overlay hosts `AppKitTextInteractionOverlay`, which wraps an + // `NSView` that handles selection gestures and context menus. The modifier also manages cursor updates, + // switching between I-beam and pointing hand based on hover position over text or links. + + typealias PlatformTextSelectionInteraction = AppKitTextSelectionInteraction + + struct AppKitTextSelectionInteraction: ViewModifier { + @State private var cursorPushed = false + + private let model: TextSelectionModel + + init(model: TextSelectionModel) { + self.model = model + } + + func body(content: Content) -> some View { + content + // We need the selection model at text fragment level for the + // text selection background and selected attachment dimming + .environment(model) + .overlayPreferenceValue(OverflowFrameKey.self) { frames in + AppKitTextInteractionOverlay(model: model, overflowFrames: frames) + .onContinuousHover { phase in + updateCursor(for: phase, model: model) + } + } + } + + private func updateCursor(for phase: HoverPhase, model: TextSelectionModel) { + switch phase { + case .active(let location): + let cursor = + model.url(for: location) != nil + ? NSCursor.pointingHand + : NSCursor.iBeam + if !cursorPushed { + cursor.push() + cursorPushed = true + } else { + cursor.set() + } + case .ended: + if cursorPushed { + NSCursor.pop() + cursorPushed = false + } + } + } + } +#endif diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/AppKit/AppKitTextSelectionView.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/AppKit/AppKitTextSelectionView.swift new file mode 100644 index 000000000..2c793a892 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/AppKit/AppKitTextSelectionView.swift @@ -0,0 +1,54 @@ +#if TEXTUAL_ENABLE_TEXT_SELECTION && canImport(AppKit) && !targetEnvironment(macCatalyst) + import SwiftUI + + // MARK: - Overview + // + // `AppKitTextSelectionView` renders selection highlights for a single `Text.Layout`. + // + // Each text fragment provides its own resolved layout and origin. The view reads the shared + // `TextSelectionModel` from the environment, computes selection rectangles for the current + // range within this layout, and paints them in a `Canvas` behind the text. + + struct AppKitTextSelectionView: View { + @Environment(TextSelectionModel.self) private var textSelectionModel: TextSelectionModel? + @State private var selectionRects: [TextSelectionRect] = [] + + private let layout: Text.Layout + private let origin: CGPoint + + init(layout: Text.Layout, origin: CGPoint) { + self.layout = layout + self.origin = origin + } + + var body: some View { + Group { + if selectionRects.isEmpty { + Color.clear + } else { + Canvas { context, _ in + context.translateBy(x: origin.x, y: origin.y) + for selectionRect in selectionRects { + context.fill( + Path(selectionRect.rect.integral), + with: .color(.init(nsColor: .selectedTextBackgroundColor)) + ) + } + } + } + } + .onChange(of: textSelectionModel?.selectedRange, initial: true, updateSelectionRects) + .onChange(of: layout, initial: true, updateSelectionRects) + } + + private func updateSelectionRects() { + if let textSelectionModel, + let selectedRange = textSelectionModel.selectedRange + { + selectionRects = textSelectionModel.selectionRects(for: selectedRange, layout: layout) + } else { + selectionRects = [] + } + } + } +#endif diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/AppKit/NSTextInteractionView.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/AppKit/NSTextInteractionView.swift new file mode 100644 index 000000000..f1b06f7ad --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/AppKit/NSTextInteractionView.swift @@ -0,0 +1,317 @@ +#if TEXTUAL_ENABLE_TEXT_SELECTION && canImport(AppKit) && !targetEnvironment(macCatalyst) + import SwiftUI + + // MARK: - Overview + // + // `NSTextInteractionView` implements selection and link interaction on macOS. + // + // The view sits in an overlay above one or more rendered `Text` fragments. It uses + // `TextSelectionModel` for hit testing and range manipulation, and it respects `exclusionRects` + // so embedded scrollable regions continue to receive input events. Link taps are forwarded to + // `openURL`. + + final class NSTextInteractionView: NSView { + var model: TextSelectionModel + var exclusionRects: [CGRect] + var openURL: OpenURLAction + + override var acceptsFirstResponder: Bool { true } + override var isFlipped: Bool { true } + override var mouseDownCanMoveWindow: Bool { false } + + private var dragStart: TextPosition? + private var selectionAnchor: TextPosition? + + init( + model: TextSelectionModel, + exclusionRects: [CGRect], + openURL: OpenURLAction + ) { + self.model = model + self.exclusionRects = exclusionRects + self.openURL = openURL + + super.init(frame: .zero) + self.wantsLayer = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func hitTest(_ point: NSPoint) -> NSView? { + let localPoint = convert(point, from: superview) + let isExcluded = exclusionRects.contains { + $0.contains(localPoint) + } + + if isExcluded { + return nil + } else { + return super.hitTest(point) + } + } + + override func mouseDown(with event: NSEvent) { + window?.makeFirstResponder(self) + let location = convert(event.locationInWindow, from: nil) + + switch event.clickCount { + case 1: + if let url = model.url(for: location) { + openURL(url) + } else { + resetSelection() + } + dragStart = model.closestPosition(to: location) + case 2: + if let position = model.closestPosition(to: location) { + model.selectedRange = model.wordRange(for: position) + } + dragStart = nil + case 3: + if let position = model.closestPosition(to: location) { + model.selectedRange = model.blockRange(for: position) + } + dragStart = nil + default: + break + } + } + + override func mouseDragged(with event: NSEvent) { + guard let dragStart else { + return + } + + let location = convert(event.locationInWindow, from: nil) + + guard let currentPosition = model.closestPosition(to: location) else { + return + } + + model.selectedRange = TextRange(from: dragStart, to: currentPosition) + autoscroll(with: event) + } + + override func mouseUp(with event: NSEvent) { + dragStart = nil + } + + override func rightMouseDown(with event: NSEvent) { + let location = convert(event.locationInWindow, from: nil) + updateSelectionForContextMenu(at: location) + + NSMenu.popUpContextMenu(makeContextMenu(), with: event, for: self) + } + + override func menu(for event: NSEvent) -> NSMenu? { + let location = convert(event.locationInWindow, from: nil) + updateSelectionForContextMenu(at: location) + + return makeContextMenu() + } + + override func selectAll(_ sender: Any?) { + model.selectedRange = TextRange(start: model.startPosition, end: model.endPosition) + } + + override func keyDown(with event: NSEvent) { + interpretKeyEvents([event]) + } + + override func moveRightAndModifySelection(_ sender: Any?) { + modifySelection { position, _ in + model.position(from: position, offset: 1) + } + } + + override func moveLeftAndModifySelection(_ sender: Any?) { + modifySelection { position, _ in + model.position(from: position, offset: -1) + } + } + + override func moveUpAndModifySelection(_ sender: Any?) { + modifySelection { position, anchor in + model.positionAbove(position, anchor: anchor) + } + } + + override func moveDownAndModifySelection(_ sender: Any?) { + modifySelection { position, anchor in + model.positionBelow(position, anchor: anchor) + } + } + + override func moveWordRightAndModifySelection(_ sender: Any?) { + modifySelection { position, _ in + model.nextWord(from: position) + } + } + + override func moveWordLeftAndModifySelection(_ sender: Any?) { + modifySelection { position, _ in + model.previousWord(from: position) + } + } + + override func moveParagraphBackwardAndModifySelection(_ sender: Any?) { + modifySelection { position, _ in + model.blockStart(for: position) + } + } + + override func moveParagraphForwardAndModifySelection(_ sender: Any?) { + modifySelection { position, _ in + model.blockEnd(for: position) + } + } + + private func updateSelectionForContextMenu(at location: CGPoint) { + guard let position = model.closestPosition(to: location) else { + resetSelection() + return + } + + if let selectedRange = model.selectedRange, selectedRange.contains(position) { + // do nothing + return + } + + model.selectedRange = model.wordRange(for: position) + } + + private func makeContextMenu() -> NSMenu { + let contextMenu = NSMenu() + + guard let selectedRange = model.selectedRange, !selectedRange.isCollapsed else { + return contextMenu + } + + // Get the localized title for the share action + let sharingPicker = NSSharingServicePicker(items: []) + let shareActionTitle = sharingPicker.standardShareMenuItem.title + + // Get the localized title for the copy action + let copyActionTitle = + if let defaultMenu = NSTextView.defaultMenu, + let copyAction = defaultMenu.items.first(where: { $0.action == #selector(copy(_:)) }) + { + copyAction.title + } else { + NSLocalizedString("Copy", bundle: .main, comment: "") + } + + contextMenu.addItem( + .init( + title: shareActionTitle, + action: #selector(share(_:)), + keyEquivalent: "" + ) + ) + contextMenu.addItem(.separator()) + contextMenu.addItem( + .init( + title: copyActionTitle, + action: #selector(copy(_:)), + keyEquivalent: "" + ) + ) + + return contextMenu + } + + private func modifySelection( + _ transform: (_ position: TextPosition, _ anchor: TextPosition) -> TextPosition? + ) { + guard let selectedRange = model.selectedRange else { + return + } + + // set anchor on first move + selectionAnchor = selectionAnchor ?? selectedRange.start + + guard let selectionAnchor else { + return + } + + // modify the non-anchor end of the selection + let position = + selectionAnchor == selectedRange.start + ? selectedRange.end + : selectedRange.start + + guard let newPosition = transform(position, selectionAnchor) else { + return + } + model.selectedRange = TextRange(from: selectionAnchor, to: newPosition) + + // scroll to make the new position visible + let caretRect = model.caretRect(for: newPosition) + scrollToVisible(caretRect) + } + + private func resetSelection() { + model.selectedRange = nil + selectionAnchor = nil + } + + @objc private func share(_ sender: Any?) { + guard let selectedRange = model.selectedRange else { + return + } + + let attributedText = model.attributedText(in: selectedRange) + let transferableText = TransferableText(attributedString: attributedText) + let itemProvider = NSItemProvider(object: transferableText) + + let sharingPicker = NSSharingServicePicker(items: [itemProvider]) + let rect = + model.selectionRects(for: selectedRange) + .last?.rect.integral ?? .zero + + sharingPicker.show(relativeTo: rect, of: self, preferredEdge: .maxY) + } + + @objc private func copy(_ sender: Any?) { + guard let selectedRange = model.selectedRange else { + return + } + + let attributedText = model.attributedText(in: selectedRange) + + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + + let formatter = Formatter(attributedText) + pasteboard.setString(formatter.plainText(), forType: .string) + pasteboard.setString(formatter.html(), forType: .html) + } + } + + extension NSTextInteractionView: NSUserInterfaceValidations { + func validateUserInterfaceItem(_ item: any NSValidatedUserInterfaceItem) -> Bool { + switch item.action { + case #selector(selectAll(_:)): + return model.hasText + case #selector(copy(_:)): + guard let selectedRange = model.selectedRange else { + return false + } + return !selectedRange.isCollapsed + case #selector(moveRightAndModifySelection(_:)), + #selector(moveLeftAndModifySelection(_:)), + #selector(moveUpAndModifySelection(_:)), + #selector(moveDownAndModifySelection(_:)), + #selector(moveWordRightAndModifySelection(_:)), + #selector(moveWordLeftAndModifySelection(_:)), + #selector(moveParagraphBackwardAndModifySelection(_:)), + #selector(moveParagraphForwardAndModifySelection(_:)): + return model.selectedRange != nil + default: + return true + } + } + } +#endif diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/IndexPath+TextPosition.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/IndexPath+TextPosition.swift new file mode 100644 index 000000000..9b5eeee5c --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/IndexPath+TextPosition.swift @@ -0,0 +1,86 @@ +#if TEXTUAL_ENABLE_TEXT_SELECTION + import Foundation + + // MARK: - Overview + // + // Selection geometry is indexed using a four-component `IndexPath`: + // + // `layout` → `line` → `run` → `runSlice` + // + // This mirrors SwiftUI’s resolved `Text.Layout` structure while staying independent of AppKit + // and UIKit. `IndexPathSequence` builds an inclusive/exclusive traversal over run slices based + // on the start/end affinities of a `TextRange`. + + extension IndexPath { + var layout: Int { + self[0] + } + + var line: Int { + self[1] + } + + var run: Int { + self[2] + } + + var runSlice: Int { + self[3] + } + + init(runSlice: Int, run: Int, line: Int, layout: Int) { + self.init(indexes: [layout, line, run, runSlice]) + } + + init(run: Int, line: Int, layout: Int) { + self.init(runSlice: 0, run: run, line: line, layout: layout) + } + + init(line: Int, layout: Int) { + self.init(runSlice: 0, run: 0, line: line, layout: layout) + } + + init(layout: Int) { + self.init(runSlice: 0, run: 0, line: 0, layout: layout) + } + } + + struct IndexPathSequence: Sequence, IteratorProtocol { + private var current: IndexPath? + private let end: IndexPath? + private let _next: (IndexPath) -> IndexPath? + + init( + range: TextRange, + next: @escaping (IndexPath) -> IndexPath?, + previous: @escaping (IndexPath) -> IndexPath? + ) { + self.current = + if range.start.affinity == .upstream { + // If start is at upstream (trailing edge), skip the start slice + next(range.start.indexPath) + } else { + range.start.indexPath + } + self.end = + if range.end.affinity == .downstream { + // If end is at downstream (leading edge), do not include the end slice + previous(range.end.indexPath) + } else { + range.end.indexPath + } + self._next = next + } + + mutating func next() -> IndexPath? { + guard let current, let end else { return nil } + let value = current + if value == end { + self.current = nil + } else { + self.current = _next(current) + } + return value + } + } +#endif diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/LayoutDirection+Locale.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/LayoutDirection+Locale.swift new file mode 100644 index 000000000..da6431eb8 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/LayoutDirection+Locale.swift @@ -0,0 +1,17 @@ +import Foundation +import SwiftUI + +extension LayoutDirection { + static func localeBased(locale: Locale = .current) -> LayoutDirection { + switch locale.language.characterDirection { + case .rightToLeft: + return .rightToLeft + case .leftToRight: + return .leftToRight + case .topToBottom, .bottomToTop, .unknown: + return .leftToRight + @unknown default: + return .leftToRight + } + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/LinkAttribute.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/LinkAttribute.swift new file mode 100644 index 000000000..20d73e3cd --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/LinkAttribute.swift @@ -0,0 +1,15 @@ +import SwiftUI + +struct LinkAttribute: TextAttribute { + var url: URL + + init(_ url: URL) { + self.url = url + } +} + +extension Text.Layout.Run { + var url: URL? { + self[LinkAttribute.self]?.url + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/EmptyTextLayoutCollection.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/EmptyTextLayoutCollection.swift new file mode 100644 index 000000000..c8fee0062 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/EmptyTextLayoutCollection.swift @@ -0,0 +1,21 @@ +#if TEXTUAL_ENABLE_TEXT_SELECTION + import SwiftUI + + struct EmptyTextLayoutCollection: TextLayoutCollection { + var layouts: [any TextLayout] { + [] + } + + func isEqual(to other: any TextLayoutCollection) -> Bool { + other.layouts.isEmpty + } + + func needsPositionReconciliation(with other: any TextLayoutCollection) -> Bool { + false + } + + func index(of layout: Text.Layout) -> Int? { + nil + } + } +#endif diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/Layout+Internals.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/Layout+Internals.swift new file mode 100644 index 000000000..a23a05a3a --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/Layout+Internals.swift @@ -0,0 +1,150 @@ +#if TEXTUAL_ENABLE_TEXT_SELECTION + import CoreText + import SwiftUI + + extension Text.Layout { + struct Contents { + let lineFragments: [NSTextLineFragment] + let layoutAttributedStrings: [NSAttributedString] + let attributedStrings: [NSAttributedString] + } + + func materializeContents() -> Contents { + let lineFragments = self.compactMap(\.lineFragment) + let layoutAttributedStrings = + lineFragments + .map(\.attributedString) + .removingIdenticalDuplicates() + + // Attachment attributes are replaced by placeholders in the rendering pipeline (see TextFragment) + // We need to re-apply them to make them part of the contents during copy operations + let attributedStrings = layoutAttributedStrings.map { input in + // Get all the lines in the layout that reference the input + let lines = zip(self, lineFragments) + .filter { $1.attributedString === input } + .map(\.0) + + return input.applyingAttachments(in: lines) + } + + return .init( + lineFragments: lineFragments, + layoutAttributedStrings: layoutAttributedStrings, + attributedStrings: attributedStrings + ) + } + } + + extension Text.Layout.Line { + var lineFragment: NSTextLineFragment? { + let mirror = Mirror(reflecting: self) + + if #available(macOS 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) { + return mirror.descendant("_line", "nsLine", 0) as? NSTextLineFragment + } else { + return mirror.descendant("_line", "nsLine") as? NSTextLineFragment + } + } + } + + extension Text.Layout.Run { + var characterRanges: [Range] { + guard let ctRun else { return [] } + assert(CTRunGetGlyphCount(ctRun) == self.count) + + let runRange = CTRunGetStringRange(ctRun) + let start = runRange.location + let end = start + runRange.length + + var characterIndices: [CFIndex] + if let pointer = CTRunGetStringIndicesPtr(ctRun) { + characterIndices = Array(UnsafeBufferPointer(start: pointer, count: count)) + } else { + characterIndices = Array(repeating: 0, count: self.count) + CTRunGetStringIndices(ctRun, .init(), &characterIndices) + } + + var characterRanges: [Range] = [] + characterRanges.reserveCapacity(self.count) + + for i in 0..= 0, characterIndices[j] == characterIndex { j -= 1 } + boundary = (j >= 0) ? characterIndices[j] : end + } + + let lowerBound = Swift.max(Swift.min(characterIndex, boundary), start) + let upperBound = Swift.min(Swift.max(characterIndex, boundary), end) + + characterRanges.append(lowerBound.. NSAttributedString { + guard lines.containsAttachments else { + return self + } + + let result = NSMutableAttributedString(attributedString: self) + + for line in lines { + for run in line { + guard + let attachment = run.attachment, + let range = run.characterRanges.first + else { continue } + + result.addAttribute(.textual.attachment, value: attachment, range: NSRange(range)) + + if let presentationIntent = run.attachmentPresentationIntent { + result.addAttribute( + .textual.presentationIntent, value: presentationIntent, range: NSRange(range)) + } + } + } + + return result + } + } + + extension Array where Element == Text.Layout.Line { + fileprivate var containsAttachments: Bool { + self.contains { line in + line.contains { run in + run.attachment != nil + } + } + } + } +#endif diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/LiveTextLayoutCollection.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/LiveTextLayoutCollection.swift new file mode 100644 index 000000000..30988f324 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/LiveTextLayoutCollection.swift @@ -0,0 +1,199 @@ +#if TEXTUAL_ENABLE_TEXT_SELECTION + import SwiftUI + + final class LiveTextLayoutCollection: TextLayoutCollection { + private(set) lazy var layouts: [any TextLayout] = makeLayouts() + + private let base: Text.LayoutKey.Value + private let geometry: GeometryProxy + + init(base: Text.LayoutKey.Value, geometry: GeometryProxy) { + self.base = base + self.geometry = geometry + } + + func isEqual(to other: any TextLayoutCollection) -> Bool { + base == (other as? LiveTextLayoutCollection)?.base + } + + func needsPositionReconciliation(with other: any TextLayoutCollection) -> Bool { + // Same layouts with different origins do not need position reconciliation + base.map(\.layout) != (other as? LiveTextLayoutCollection)?.base.map(\.layout) + } + + func index(of layout: Text.Layout) -> Int? { + layouts.firstIndex { textLayout in + (textLayout as? LiveTextLayout)?.base == layout + } + } + + private func makeLayouts() -> [any TextLayout] { + base + // We are only interested in text fragments + .filter(\.layout.isTextFragment) + .map { anchoredLayout in + LiveTextLayout( + anchoredLayout: anchoredLayout, + geometry: geometry + ) + } + } + } + + final class LiveTextLayout: TextLayout { + var attributedString: NSAttributedString { + joinedAttributedString.joined + } + + let origin: CGPoint + + private(set) lazy var bounds: CGRect = makeBounds() + private(set) lazy var lines: [any TextLine] = makeLines() + + let base: Text.Layout + + private lazy var contents = base.materializeContents() + private lazy var joinedAttributedString = contents.attributedStrings.joined() + + convenience init( + anchoredLayout: Text.LayoutKey.AnchoredLayout, + geometry: GeometryProxy + ) { + self.init( + base: anchoredLayout.layout, + origin: geometry[anchoredLayout.origin] + ) + } + + init(base: Text.Layout, origin: CGPoint) { + self.base = base + self.origin = origin + } + + private func makeBounds() -> CGRect { + base.map(\.typographicBounds.rect) + .reduce(CGRect.null, CGRectUnion) + } + + private func makeLines() -> [any TextLine] { + guard contents.attributedStrings.count > 1 else { + return base.map { + LiveTextLine(base: $0) + } + } + + // Get the offset mappings on the layout strings to maintain object identity + let (_, characterOffsets) = contents.layoutAttributedStrings.joined() + + return zip(base, contents.lineFragments).compactMap { line, lineFragment in + guard let offset = characterOffsets[.init(lineFragment.attributedString)] else { + return nil + } + + return LiveTextLine(base: line, offset: offset) + } + } + } + + final class LiveTextLine: TextLine { + var origin: CGPoint { + base.origin + } + + var typographicBounds: CGRect { + base.typographicBounds.rect + } + + private(set) lazy var runs: [any TextRun] = makeRuns() + + let base: Text.Layout.Line + let offset: Int + + init(base: Text.Layout.Line, offset: Int = 0) { + self.base = base + self.offset = offset + } + + private func makeRuns() -> [any TextRun] { + if base.isEmpty { + // Return a newline run for empty lines + return [ + EmptyRun( + typographicBounds: base.typographicBounds.rect, + slice: .init( + typographicBounds: base.typographicBounds.rect, + characterRange: offset..<(offset + 1) + ) + ) + ] + } else { + return base.map { run in + LiveTextRun(base: run, offset: offset) + } + } + } + } + + final class LiveTextRun: TextRun { + var layoutDirection: LayoutDirection { + base.layoutDirection + } + + var typographicBounds: CGRect { + base.typographicBounds.rect + } + + var url: URL? { + base.url + } + + private(set) lazy var slices: [any TextRunSlice] = makeRunSlices() + + let base: Text.Layout.Run + let offset: Int + + init(base: Text.Layout.Run, offset: Int) { + self.base = base + self.offset = offset + } + + private func makeRunSlices() -> [any TextRunSlice] { + zip(base, base.characterRanges).map { slice, characterRange in + LiveTextRunSlice( + base: slice, + characterRange: characterRange.offset(by: offset) + ) + } + } + } + + struct EmptyRun: TextRun { + let layoutDirection: LayoutDirection = .localeBased() + let typographicBounds: CGRect + let url: URL? = nil + let slice: EmptyRunSlice + + var slices: [any TextRunSlice] { + [slice] + } + } + + final class LiveTextRunSlice: TextRunSlice { + var typographicBounds: CGRect { + base.typographicBounds.rect + } + + let characterRange: Range + let base: Text.Layout.RunSlice + + init(base: Text.Layout.RunSlice, characterRange: Range) { + self.base = base + self.characterRange = characterRange + } + } + + struct EmptyRunSlice: TextRunSlice { + let typographicBounds: CGRect + let characterRange: Range + } +#endif diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+AttributedStringAccess.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+AttributedStringAccess.swift new file mode 100644 index 000000000..fdcac4d6f --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+AttributedStringAccess.swift @@ -0,0 +1,83 @@ +#if TEXTUAL_ENABLE_TEXT_SELECTION + import SwiftUI + + extension TextLayoutCollection { + var stringLength: Int { + layouts.map(\.attributedString.length).reduce(0, +) + } + + func attributedText(in range: TextRange) -> NSAttributedString { + guard !range.isCollapsed else { return NSAttributedString() } + + let attributedText = NSMutableAttributedString() + let start = range.start.indexPath.layout + let end = range.end.indexPath.layout + + for layout in start...end { + let attributedString = layouts[layout].attributedString + + let lowerBound = + (layout == start) + ? localCharacterIndex(at: range.start) + : 0 + let upperBound = + (layout == end) + ? localCharacterIndex(at: range.end) + : attributedString.length + + if lowerBound < upperBound { + attributedText.append( + attributedString.attributedSubstring( + from: NSRange(lowerBound.. NSRange? { + #if os(macOS) + guard + NSRange(location: 0, length: attributedString.length) + .contains(characterIndex) + else { + return nil + } + return attributedString.doubleClick(at: characterIndex) + #else + nil + #endif + } + } + + extension NSAttributedString { + @available(macOS 10.0, *) + @available(iOS, unavailable) + @available(visionOS, unavailable) + func nextWord(from characterIndex: Int) -> Int { + #if os(macOS) + nextWord(from: characterIndex, forward: true) + #else + 0 + #endif + } + + @available(macOS 10.0, *) + @available(iOS, unavailable) + @available(visionOS, unavailable) + func previousWord(from characterIndex: Int) -> Int { + #if os(macOS) + nextWord(from: characterIndex, forward: false) + #else + 0 + #endif + } + } +#endif diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+Geometry.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+Geometry.swift new file mode 100644 index 000000000..45c83a495 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+Geometry.swift @@ -0,0 +1,357 @@ +#if TEXTUAL_ENABLE_TEXT_SELECTION + import SwiftUI + + extension TextLayoutCollection { + func url(for point: CGPoint) -> URL? { + guard let layout = layouts.first(where: { $0.frame.contains(point) }) else { + return nil + } + + let localPoint = CGPoint( + x: point.x - layout.origin.x, + y: point.y - layout.origin.y + ) + + let url = layout.runs.first { + $0.typographicBounds.contains(localPoint) + }?.url + + return url + } + + func firstRect(for range: TextRange) -> CGRect { + guard !range.isCollapsed else { + return caretRect(for: range.start) + } + + var firstRect = CGRect.null + let layout = range.start.indexPath.layout + let line = range.start.indexPath.line + + for indexPath in indexPathsForRunSlices(in: range) { + guard indexPath.layout == layout, indexPath.line == line else { + break + } + firstRect = firstRect.union(runSliceSelectionRect(at: indexPath)) + } + + return firstRect + } + + func caretRect(for position: TextPosition) -> CGRect { + let runSliceRect = runSliceRect(at: position.indexPath) + let lineRect = lineRect(at: position.indexPath) + let layoutDirection = layoutDirection(at: position.indexPath) + let x = + (position.affinity == .downstream) + ? runSliceRect.leadingEdgeX(for: layoutDirection) + : runSliceRect.trailingEdgeX(for: layoutDirection) + + return CGRect(x: x, y: lineRect.minY, width: 1, height: lineRect.height) + } + + func closestPosition(to point: CGPoint) -> TextPosition? { + guard !layouts.isEmpty else { return nil } + + let layoutIndex = layoutIndex(closestTo: point) + let layout = layouts[layoutIndex] + + guard !layout.lines.isEmpty else { return nil } + + let localPoint = CGPoint( + x: point.x - layout.origin.x, + y: point.y - layout.origin.y + ) + + let lineIndex = layout.lineIndex(closestToY: localPoint.y) + let line = layout.lines[lineIndex] + let runIndex = line.runIndex(closestToX: localPoint.x) + let run = line.runs[runIndex] + let direction = run.layoutDirection + + let runSliceIndex = run.sliceIndex(closestToX: localPoint.x) + let runSlice = run.slices[runSliceIndex] + + let leadingDistance = abs( + localPoint.x - runSlice.typographicBounds.leadingEdgeX(for: direction) + ) + let trailingDistance = abs( + localPoint.x - runSlice.typographicBounds.trailingEdgeX(for: direction) + ) + + return TextPosition( + indexPath: .init( + runSlice: runSliceIndex, + run: runIndex, + line: lineIndex, + layout: layoutIndex + ), + affinity: (leadingDistance <= trailingDistance) ? .downstream : .upstream + ) + } + + func characterRange(at point: CGPoint) -> TextRange? { + guard !layouts.isEmpty else { return nil } + + let layoutIndex = layoutIndex(closestTo: point) + let layout = layouts[layoutIndex] + + guard !layout.lines.isEmpty else { return nil } + + let localPoint = CGPoint( + x: point.x - layout.origin.x, + y: point.y - layout.origin.y + ) + + let lineIndex = layout.lineIndex(closestToY: localPoint.y) + let line = layout.lines[lineIndex] + let runIndex = line.runIndex(closestToX: localPoint.x) + let run = line.runs[runIndex] + let runSliceIndex = run.sliceIndex(closestToX: localPoint.x) + + let start = TextPosition( + indexPath: .init( + runSlice: runSliceIndex, run: runIndex, line: lineIndex, layout: layoutIndex), + affinity: .downstream + ) + let end = TextPosition( + indexPath: .init( + runSlice: runSliceIndex, run: runIndex, line: lineIndex, layout: layoutIndex), + affinity: .upstream + ) + + return TextRange(start: start, end: end) + } + + func isPositionAtBlockBoundary(_ position: TextPosition) -> Bool { + if position + == TextPosition( + indexPath: .init(layout: position.indexPath.layout), + affinity: .downstream + ) + { + return true + } + + let layout = layouts[position.indexPath.layout] + + guard + let line = layout.lines.last, + let run = line.runs.last + else { + return false + } + + if position + == TextPosition( + indexPath: .init( + runSlice: run.slices.endIndex - 1, + run: line.runs.endIndex - 1, + line: layout.lines.endIndex - 1, + layout: position.indexPath.layout + ), + affinity: .upstream + ) + { + return true + } + + return false + } + + func positionAbove(_ position: TextPosition, anchor: TextPosition) -> TextPosition? { + let anchorX = runSliceRect(at: anchor.indexPath).midX + + if position.indexPath.line > 0 { + return closestPosition( + to: anchorX, + layoutIndex: position.indexPath.layout, + lineIndex: position.indexPath.line - 1 + ) + } + + if position.indexPath.layout > 0 { + let previousLayout = layouts[position.indexPath.layout - 1] + let lastLineIndex = previousLayout.lines.endIndex - 1 + return closestPosition( + to: anchorX, + layoutIndex: position.indexPath.layout - 1, + lineIndex: lastLineIndex + ) + } + + return startPosition + } + + func positionBelow(_ position: TextPosition, anchor: TextPosition) -> TextPosition? { + let anchorX = runSliceRect(at: anchor.indexPath).midX + let layout = layouts[position.indexPath.layout] + + if position.indexPath.line + 1 < layout.lines.endIndex { + return closestPosition( + to: anchorX, + layoutIndex: position.indexPath.layout, + lineIndex: position.indexPath.line + 1 + ) + } + + if position.indexPath.layout + 1 < layouts.endIndex { + return closestPosition( + to: anchorX, + layoutIndex: position.indexPath.layout + 1, + lineIndex: 0 + ) + } + + return endPosition + } + + func runSliceSelectionRect(at indexPath: IndexPath) -> CGRect { + let layout = layouts[indexPath.layout] + let line = layout.lines[indexPath.line] + let runSlice = line.runs[indexPath.run].slices[indexPath.runSlice] + + var rect = runSlice.typographicBounds + rect.origin.y = line.typographicBounds.minY + rect.size.height = line.typographicBounds.height + + return rect.offsetBy(dx: layout.origin.x, dy: layout.origin.y) + } + } + + extension TextLayoutCollection { + fileprivate func closestPosition( + to x: CGFloat, + layoutIndex: Int, + lineIndex: Int + ) -> TextPosition? { + let layout = layouts[layoutIndex] + let line = layout.lines[lineIndex] + let runIndex = line.runIndex(closestToX: x) + let run = line.runs[runIndex] + let direction = run.layoutDirection + + let runSliceIndex = run.sliceIndex(closestToX: x) + let runSlice = run.slices[runSliceIndex] + + let leadingDistance = abs( + x - runSlice.typographicBounds.leadingEdgeX(for: direction) + ) + let trailingDistance = abs( + x - runSlice.typographicBounds.trailingEdgeX(for: direction) + ) + + return TextPosition( + indexPath: .init( + runSlice: runSliceIndex, + run: runIndex, + line: lineIndex, + layout: layoutIndex + ), + affinity: (leadingDistance <= trailingDistance) ? .downstream : .upstream + ) + } + + fileprivate func runSliceRect(at indexPath: IndexPath) -> CGRect { + let layout = layouts[indexPath.layout] + let runSlice = layout.lines[indexPath.line].runs[indexPath.run].slices[indexPath.runSlice] + return runSlice.typographicBounds.offsetBy(dx: layout.origin.x, dy: layout.origin.y) + } + + fileprivate func lineRect(at indexPath: IndexPath) -> CGRect { + let layout = layouts[indexPath.layout] + let line = layout.lines[indexPath.line] + return line.typographicBounds.offsetBy(dx: layout.origin.x, dy: layout.origin.y) + } + + fileprivate func layoutIndex(closestTo point: CGPoint) -> Int { + var closestIndex = 0 + var closestDistance = CGFloat.greatestFiniteMagnitude + for (index, layout) in zip(layouts.indices, layouts) { + let distance = layout.frame.distanceSquared(to: point) + if distance < closestDistance { + closestDistance = distance + closestIndex = index + } + } + return closestIndex + } + } + + extension TextLayout { + fileprivate func lineIndex(closestToY y: CGFloat) -> Int { + var closestIndex = 0 + var closestDistance = CGFloat.greatestFiniteMagnitude + for (index, line) in lines.enumerated() { + let distance = line.typographicBounds.verticalDistance(to: y) + if distance < closestDistance { + closestDistance = distance + closestIndex = index + } + } + return closestIndex + } + } + + extension TextLine { + fileprivate func runIndex(closestToX x: CGFloat) -> Int { + var closestIndex = 0 + var closestDistance = CGFloat.greatestFiniteMagnitude + for (index, run) in runs.enumerated() { + let distance = run.typographicBounds.horizontalDistance(to: x) + if distance < closestDistance { + closestDistance = distance + closestIndex = index + } + } + return closestIndex + } + } + + extension TextRun { + fileprivate func sliceIndex(closestToX x: CGFloat) -> Int { + var closestIndex = 0 + var closestDistance = CGFloat.greatestFiniteMagnitude + for (index, slice) in slices.enumerated() { + let distance = slice.typographicBounds.horizontalDistance(to: x) + if distance < closestDistance { + closestDistance = distance + closestIndex = index + } + } + return closestIndex + } + } + + extension CGRect { + fileprivate func leadingEdgeX(for layoutDirection: LayoutDirection) -> CGFloat { + layoutDirection == .leftToRight ? minX : maxX + } + + fileprivate func trailingEdgeX(for layoutDirection: LayoutDirection) -> CGFloat { + layoutDirection == .leftToRight ? maxX : minX + } + + // Vertical distance from this rect to a Y coordinate in the same space. + // Returns 0 when `y` is inside the rect’s vertical span. + fileprivate func verticalDistance(to y: CGFloat) -> CGFloat { + if y < minY { return minY - y } + if y > maxY { return y - maxY } + return 0 + } + + // Horizontal distance from this rect to an X coordinate in the same space. + // Returns 0 when `x` is inside the rect’s horizontal span. + fileprivate func horizontalDistance(to x: CGFloat) -> CGFloat { + if x < minX { return minX - x } + if x > maxX { return x - maxX } + return 0 + } + + fileprivate func distanceSquared(to point: CGPoint) -> CGFloat { + let dx = horizontalDistance(to: point.x) + let dy = verticalDistance(to: point.y) + return dx * dx + dy * dy + } + } +#endif diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+Positioning.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+Positioning.swift new file mode 100644 index 000000000..1a7090d3b --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+Positioning.swift @@ -0,0 +1,310 @@ +#if TEXTUAL_ENABLE_TEXT_SELECTION + import SwiftUI + + extension TextLayoutCollection { + var startPosition: TextPosition { + TextPosition( + indexPath: .init(runSlice: 0, run: 0, line: 0, layout: 0), + affinity: layouts.count > 0 ? .downstream : .upstream + ) + } + + var endPosition: TextPosition { + guard + let layout = layouts.last, + let line = layout.lines.last, + let run = line.runs.last + else { + return startPosition + } + return TextPosition( + indexPath: .init( + runSlice: run.slices.endIndex - 1, + run: line.runs.endIndex - 1, + line: layout.lines.endIndex - 1, + layout: layouts.endIndex - 1 + ), + affinity: .upstream + ) + } + + func position(from position: TextPosition, offset: Int) -> TextPosition? { + let from = characterIndex(at: position) + let target = from + offset + + guard (0...stringLength).contains(target) else { + return nil + } + + // Map target to layout and local character index + + var localTarget = target + var layout = 0 + + while layout < layouts.count { + let length = layouts[layout].attributedString.length + + guard localTarget > length else { + break + } + + localTarget -= length + layout += 1 + } + + guard layout < layouts.count else { + return endPosition + } + + return self.position(at: layout, localCharacterIndex: localTarget) + } + + func characterIndex(at position: TextPosition) -> Int { + let base = layouts.prefix(position.indexPath.layout) + .map(\.attributedString.length) + .reduce(0, +) + return base + localCharacterIndex(at: position) + } + + func localCharacterIndex(at position: TextPosition) -> Int { + let range = localCharacterRange(at: position.indexPath) + switch position.affinity { + case .downstream: return range.lowerBound + case .upstream: return range.upperBound + } + } + + func localCharacterRange(at indexPath: IndexPath) -> Range { + let line = layouts[indexPath.layout].lines[indexPath.line] + return line.runs[indexPath.run] + .slices[indexPath.runSlice] + .characterRange + } + + func layoutDirection(at indexPath: IndexPath) -> LayoutDirection { + let line = layouts[indexPath.layout].lines[indexPath.line] + return line.runs[indexPath.run].layoutDirection + } + + func position(at layoutIndex: Int, localCharacterIndex: Int) -> TextPosition? { + guard localCharacterIndex > 0 else { + return TextPosition( + indexPath: .init(layout: layoutIndex), + affinity: .downstream + ) + } + + let layout = layouts[layoutIndex] + let stringLength = layout.attributedString.length + + guard localCharacterIndex <= stringLength else { + if let line = layout.lines.last, let run = line.runs.last { + return TextPosition( + indexPath: .init( + runSlice: run.slices.endIndex - 1, + run: line.runs.endIndex - 1, + line: layout.lines.endIndex - 1, + layout: layoutIndex + ), + affinity: .upstream + ) + } else { + return TextPosition( + indexPath: .init(runSlice: 0, run: 0, line: 0, layout: layoutIndex), + affinity: .upstream + ) + } + } + + for (i, line) in zip(layout.lines.indices, layout.lines) { + for (j, run) in zip(line.runs.indices, line.runs) { + for (k, slice) in zip(run.slices.indices, run.slices) { + if slice.characterRange.contains(localCharacterIndex) { + return TextPosition( + indexPath: .init( + runSlice: k, + run: j, + line: i, + layout: layoutIndex + ), + affinity: .downstream + ) + } else if slice.characterRange.upperBound == localCharacterIndex { + return TextPosition( + indexPath: .init( + runSlice: k, + run: j, + line: i, + layout: layoutIndex + ), + affinity: .upstream + ) + } + } + } + } + + return nil + } + + func reconcileRange(_ range: TextRange, from other: any TextLayoutCollection) -> TextRange? { + guard + layouts.count == other.layouts.count, + let start = reconcilePosition(range.start, from: other), + let end = reconcilePosition(range.end, from: other) + else { + return nil + } + + return TextRange(start: start, end: end) + } + + @available(macOS 10.0, *) + @available(iOS, unavailable) + @available(visionOS, unavailable) + func nextWord(from position: TextPosition) -> TextPosition? { + guard layouts.indices.contains(position.indexPath.layout) else { + return nil + } + let layout = layouts[position.indexPath.layout] + let characterIndex = localCharacterIndex(at: position) + let nextCharacterIndex = layout.attributedString.nextWord(from: characterIndex) + + if nextCharacterIndex >= layout.attributedString.length { + // try next layout + guard position.indexPath.layout + 1 < layouts.endIndex else { + return endPosition + } + + return self.position( + at: position.indexPath.layout + 1, + localCharacterIndex: 0 + ) + } + + return self.position( + at: position.indexPath.layout, + localCharacterIndex: nextCharacterIndex + ) + } + + @available(macOS 10.0, *) + @available(iOS, unavailable) + @available(visionOS, unavailable) + func previousWord(from position: TextPosition) -> TextPosition? { + guard layouts.indices.contains(position.indexPath.layout) else { + return nil + } + let layout = layouts[position.indexPath.layout] + let characterIndex = localCharacterIndex(at: position) + let previousCharacterIndex = layout.attributedString.previousWord(from: characterIndex) + + if previousCharacterIndex == 0 && characterIndex == 0 { + // Try previous layout + guard position.indexPath.layout > 0 else { + return startPosition + } + + let previousLayout = layouts[position.indexPath.layout - 1] + + guard + let position = self.position( + at: position.indexPath.layout - 1, + localCharacterIndex: previousLayout.attributedString.length - 1 + ) + else { + return nil + } + + return previousWord(from: position) + } + + return self.position( + at: position.indexPath.layout, + localCharacterIndex: previousCharacterIndex + ) + } + + func blockStart(for position: TextPosition) -> TextPosition? { + guard layouts.indices.contains(position.indexPath.layout) else { + return nil + } + + let start = TextPosition( + indexPath: .init(layout: position.indexPath.layout), + affinity: .downstream + ) + + // if we are already at the start, move to the previous block + if position == start, position.indexPath.layout > 0 { + return TextPosition( + indexPath: .init(layout: position.indexPath.layout - 1), + affinity: .downstream + ) + } + + return start + } + + func blockEnd(for position: TextPosition) -> TextPosition? { + guard layouts.indices.contains(position.indexPath.layout) else { + return nil + } + + let layout = layouts[position.indexPath.layout] + + guard + let line = layout.lines.last, + let run = line.runs.last + else { + return nil + } + + let end = TextPosition( + indexPath: .init( + runSlice: run.slices.endIndex - 1, + run: line.runs.endIndex - 1, + line: layout.lines.endIndex - 1, + layout: position.indexPath.layout + ), + affinity: .upstream + ) + + // if we are already at the end, move to the next block + if position == end, position.indexPath.layout + 1 < layouts.endIndex { + let layout = layouts[position.indexPath.layout + 1] + + guard + let line = layout.lines.last, + let run = line.runs.last + else { + return nil + } + + return TextPosition( + indexPath: .init( + runSlice: run.slices.endIndex - 1, + run: line.runs.endIndex - 1, + line: layout.lines.endIndex - 1, + layout: position.indexPath.layout + 1 + ), + affinity: .upstream + ) + } + + return end + } + } + + extension TextLayoutCollection { + fileprivate func reconcilePosition( + _ position: TextPosition, + from other: any TextLayoutCollection + ) -> TextPosition? { + self.position( + at: position.indexPath.layout, + localCharacterIndex: other.localCharacterIndex(at: position) + ) + } + } +#endif diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+Ranges.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+Ranges.swift new file mode 100644 index 000000000..fe7bc9a09 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+Ranges.swift @@ -0,0 +1,99 @@ +#if TEXTUAL_ENABLE_TEXT_SELECTION + import SwiftUI + + extension TextLayoutCollection { + @available(macOS 10.0, *) + @available(iOS, unavailable) + @available(visionOS, unavailable) + func wordRange(for position: TextPosition) -> TextRange? { + guard layouts.indices.contains(position.indexPath.layout) else { + return nil + } + let layout = layouts[position.indexPath.layout] + let characterIndex = localCharacterIndex(at: position) + + guard + let range = layout.wordRange(containing: characterIndex), + let start = self.position( + at: position.indexPath.layout, + localCharacterIndex: range.lowerBound + ), + let end = self.position( + at: position.indexPath.layout, + localCharacterIndex: range.upperBound + ) + else { return nil } + + return TextRange(start: start, end: end) + } + + func blockRange(for position: TextPosition) -> TextRange? { + guard layouts.indices.contains(position.indexPath.layout) else { + return nil + } + + let layout = layouts[position.indexPath.layout] + + guard + let line = layout.lines.last, + let run = line.runs.last + else { + return nil + } + + return TextRange( + start: .init( + indexPath: .init(layout: position.indexPath.layout), + affinity: .downstream + ), + end: .init( + indexPath: .init( + runSlice: run.slices.endIndex - 1, + run: line.runs.endIndex - 1, + line: layout.lines.endIndex - 1, + layout: position.indexPath.layout + ), + affinity: .upstream + ) + ) + } + + func clampRange(_ range: TextRange, layoutIndex: Int) -> TextRange? { + guard layouts.indices.contains(layoutIndex) else { + return nil + } + + let layout = layouts[layoutIndex] + + guard + let lastLine = layout.lines.last, + let lastRun = lastLine.runs.last + else { + return nil + } + + let start = TextPosition( + indexPath: .init(layout: layoutIndex), + affinity: .downstream + ) + let end = TextPosition( + indexPath: .init( + runSlice: lastRun.slices.endIndex - 1, + run: lastLine.runs.endIndex - 1, + line: layout.lines.endIndex - 1, + layout: layoutIndex + ), + affinity: .upstream + ) + + guard range.end > start && range.start < end else { + return nil + } + + return TextRange( + start: Swift.max(range.start, start), + end: Swift.min(range.end, end) + ) + } + } +#endif diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+RunSliceTraversal.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+RunSliceTraversal.swift new file mode 100644 index 000000000..1e83a1129 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+RunSliceTraversal.swift @@ -0,0 +1,101 @@ +#if TEXTUAL_ENABLE_TEXT_SELECTION + import Foundation + + extension TextLayoutCollection { + func indexPathsForRunSlices(in range: TextRange) -> some Sequence { + IndexPathSequence( + range: range, + next: self.indexPathForRunSlice(after:), + previous: self.indexPathForRunSlice(before:) + ) + } + } + + extension TextLayoutCollection { + fileprivate func indexPathForRunSlice(after indexPath: IndexPath) -> IndexPath? { + let layout = layouts[indexPath.layout] + let line = layout.lines[indexPath.line] + let run = line.runs[indexPath.run] + + if indexPath.runSlice + 1 < run.slices.count { + return IndexPath( + runSlice: indexPath.runSlice + 1, + run: indexPath.run, + line: indexPath.line, + layout: indexPath.layout + ) + } + + if indexPath.run + 1 < line.runs.count { + return IndexPath( + run: indexPath.run + 1, + line: indexPath.line, + layout: indexPath.layout + ) + } + + if indexPath.line + 1 < layout.lines.count { + return IndexPath( + line: indexPath.line + 1, + layout: indexPath.layout + ) + } + + if indexPath.layout + 1 < layouts.count { + return IndexPath(layout: indexPath.layout + 1) + } + + return nil + } + + fileprivate func indexPathForRunSlice(before indexPath: IndexPath) -> IndexPath? { + if indexPath.runSlice > 0 { + return IndexPath( + runSlice: indexPath.runSlice - 1, + run: indexPath.run, + line: indexPath.line, + layout: indexPath.layout + ) + } + + if indexPath.run > 0 { + let previousRun = layouts[indexPath.layout].lines[indexPath.line].runs[indexPath.run - 1] + return IndexPath( + runSlice: previousRun.slices.endIndex - 1, + run: indexPath.run - 1, + line: indexPath.line, + layout: indexPath.layout + ) + } + + if indexPath.line > 0 { + let previousLine = layouts[indexPath.layout].lines[indexPath.line - 1] + let lastRunIndex = previousLine.runs.endIndex - 1 + let lastRun = previousLine.runs[lastRunIndex] + + return IndexPath( + runSlice: lastRun.slices.endIndex - 1, + run: lastRunIndex, + line: indexPath.line - 1, + layout: indexPath.layout + ) + } + + if indexPath.layout > 0 { + let previousLayout = layouts[indexPath.layout - 1] + let lastLineIndex = previousLayout.lines.endIndex - 1 + let lastLine = previousLayout.lines[lastLineIndex] + let lastRunIndex = lastLine.runs.endIndex - 1 + let lastRun = lastLine.runs[lastRunIndex] + return IndexPath( + runSlice: lastRun.slices.endIndex - 1, + run: lastRunIndex, + line: lastLineIndex, + layout: indexPath.layout - 1 + ) + } + + return nil + } + } +#endif diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+SelectionRects.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+SelectionRects.swift new file mode 100644 index 000000000..4c262afe0 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+SelectionRects.swift @@ -0,0 +1,208 @@ +#if TEXTUAL_ENABLE_TEXT_SELECTION + import SwiftUI + + // MARK: - Overview + // + // Selection rectangles are derived from run-slice geometry, then merged into spans that feel + // continuous when drawn. + // + // The algorithm walks the run slices in a range, merges adjacent slices that share a layout + // direction (to avoid breaking RTL selection into multiple rectangles), then trims the leading + // and trailing spans to caret positions. Finally, it “inflates” per-line rectangles to fill any + // vertical gaps between lines so selection highlights appear as a single block. + + extension TextLayoutCollection { + func selectionRects(for range: TextRange, layout: Text.Layout) -> [TextSelectionRect] { + guard + let layoutIndex = self.index(of: layout), + let clampedRange = self.clampRange(range, layoutIndex: layoutIndex) + else { + return [] + } + + let origin = layouts[layoutIndex].origin + + return self.selectionRects(for: clampedRange).map { rect in + rect.offsetBy(dx: -origin.x, dy: -origin.y) + } + } + + func selectionRects(for range: TextRange) -> [TextSelectionRect] { + guard !range.isCollapsed else { return [] } + + let startX = self.caretRect(for: range.start).minX + let endX = self.caretRect(for: range.end).minX + + let start = range.start.indexPath + let end = range.end.indexPath + + var selectionRects: [TextSelectionRect] = [] + var currentLayout: Int? = nil + var builder: TextSelectionRect.Builder? = nil + + func flushRects() { + selectionRects += builder?.rects() ?? [] + builder = nil + } + + for indexPath in self.indexPathsForRunSlices(in: range) { + if currentLayout != indexPath.layout { + flushRects() + currentLayout = indexPath.layout + builder = .init( + start: indexPath.layout == start.layout ? start : nil, + end: indexPath.layout == end.layout ? end : nil, + startX: startX, + endX: endX + ) + } + + let rect = runSliceSelectionRect(at: indexPath) + let layoutDirection = self.layoutDirection(at: indexPath) + + builder?.appendRect(rect, layoutDirection: layoutDirection, line: indexPath.line) + } + + flushRects() + + guard !selectionRects.isEmpty else { + return [] + } + + selectionRects[0].containsStart = true + selectionRects[selectionRects.count - 1].containsEnd = true + + return selectionRects + } + } + + extension TextSelectionRect { + fileprivate struct Builder { + let start: IndexPath? + let end: IndexPath? + let startX: CGFloat + let endX: CGFloat + + private var lines: [[TextSelectionRect]] = [] + private var currentLine: Int? + private var currentLineRects: [TextSelectionRect] = [] + + init( + start: IndexPath?, + end: IndexPath?, + startX: CGFloat, + endX: CGFloat + ) { + self.start = start + self.end = end + self.startX = startX + self.endX = endX + } + + mutating func appendRect(_ rect: CGRect, layoutDirection: LayoutDirection, line: Int) { + if currentLine != line { + appendCurrentLine() + currentLine = line + } + + if let last = currentLineRects.indices.last, + currentLineRects[last].layoutDirection == layoutDirection + { + currentLineRects[last].rect = currentLineRects[last].rect.union(rect) + } else { + currentLineRects.append(.init(rect: rect, layoutDirection: layoutDirection)) + } + } + + mutating func rects() -> [TextSelectionRect] { + appendCurrentLine() + guard !lines.isEmpty else { + return [] + } + lines.inflate() + return lines.flatMap(\.self) + } + + private mutating func appendCurrentLine() { + guard let currentLine, !currentLineRects.isEmpty else { + currentLineRects.removeAll(keepingCapacity: true) + self.currentLine = nil + return + } + + if let start, start.line == currentLine { + let span = currentLineRects.index(containing: startX) ?? currentLineRects.startIndex + currentLineRects[span].trimLeading(to: startX) + } + + if let end, end.line == currentLine { + let span = + currentLineRects.index(containing: endX) + ?? currentLineRects.index(before: currentLineRects.endIndex) + currentLineRects[span].trimTrailing(to: endX) + } + + lines.append(currentLineRects) + currentLineRects.removeAll(keepingCapacity: true) + self.currentLine = nil + } + } + } + + extension Array where Element == [TextSelectionRect] { + fileprivate mutating func inflate() { + var previousMaxY: CGFloat? = nil + + for line in self.indices { + guard !self[line].isEmpty else { + continue + } + + if let previousMaxY { + let lineMinY = self[line].first!.rect.minY + if lineMinY > previousMaxY { + let gap = lineMinY - previousMaxY + for span in self[line].indices { + self[line][span].rect.origin.y -= gap + self[line][span].rect.size.height += gap + } + } + } + + previousMaxY = self[line].first?.rect.maxY + } + } + } + + extension Array where Element == TextSelectionRect { + fileprivate func index(containing caretX: CGFloat) -> Int? { + firstIndex { + $0.rect.minX...$0.rect.maxX ~= caretX + } + } + } + + extension TextSelectionRect { + fileprivate mutating func trimLeading(to caretX: CGFloat) { + if layoutDirection == .leftToRight { + let minX = max(rect.minX, caretX) + rect.size.width = max(0, rect.maxX - minX) + rect.origin.x = minX + } else { + let maxX = max(rect.minX, caretX) + rect.size.width = Swift.max(0, maxX - rect.minX) + } + } + + fileprivate mutating func trimTrailing(to caretX: CGFloat) { + if layoutDirection == .leftToRight { + let maxX = max(rect.minX, caretX) + rect.size.width = Swift.max(0, maxX - rect.minX) + } else { + let minX = Swift.min(rect.maxX, caretX) + rect.size.width = Swift.max(0, rect.maxX - minX) + rect.origin.x = minX + } + } + } +#endif diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection.swift new file mode 100644 index 000000000..f10ffa67c --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection.swift @@ -0,0 +1,75 @@ +#if TEXTUAL_ENABLE_TEXT_SELECTION + import SwiftUI + + protocol TextLayoutCollection { + var layouts: [any TextLayout] { get } + + func isEqual(to other: any TextLayoutCollection) -> Bool + func needsPositionReconciliation(with other: any TextLayoutCollection) -> Bool + func index(of layout: Text.Layout) -> Int? + } + + struct AnyTextLayoutCollection: TextLayoutCollection, Equatable { + private let base: any TextLayoutCollection + + init(_ base: any TextLayoutCollection) { + self.base = base + } + + var layouts: [any TextLayout] { + base.layouts + } + + func isEqual(to other: any TextLayoutCollection) -> Bool { + base.isEqual(to: other) + } + + func needsPositionReconciliation(with other: any TextLayoutCollection) -> Bool { + base.needsPositionReconciliation(with: other) + } + + func index(of layout: Text.Layout) -> Int? { + base.index(of: layout) + } + + static func == (lhs: AnyTextLayoutCollection, rhs: AnyTextLayoutCollection) -> Bool { + lhs.isEqual(to: rhs.base) + } + } + + protocol TextLayout { + var attributedString: NSAttributedString { get } + var origin: CGPoint { get } + var bounds: CGRect { get } + var lines: [any TextLine] { get } + } + + extension TextLayout { + var frame: CGRect { + bounds.offsetBy(dx: origin.x, dy: origin.y) + } + + var runs: [any TextRun] { + lines.flatMap(\.runs) + } + } + + protocol TextLine { + var origin: CGPoint { get } + var typographicBounds: CGRect { get } + var runs: [any TextRun] { get } + } + + protocol TextRun { + var layoutDirection: LayoutDirection { get } + var typographicBounds: CGRect { get } + var url: URL? { get } + var slices: [any TextRunSlice] { get } + } + + protocol TextRunSlice { + var typographicBounds: CGRect { get } + var characterRange: Range { get } + } + +#endif diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/View+TextLayoutCollection.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/View+TextLayoutCollection.swift new file mode 100644 index 000000000..a1a8d0677 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextLayout/View+TextLayoutCollection.swift @@ -0,0 +1,24 @@ +#if TEXTUAL_ENABLE_TEXT_SELECTION + import SwiftUI + + // MARK: - Overview + // + // `overlayTextLayoutCollection` adapts SwiftUI’s `Text.Layout` preference values into a + // `TextLayoutCollection` that the selection system can query. + // + // The collection includes each anchored layout plus the geometry needed to convert anchors into + // concrete origins. Platform interactions and selection rendering use the collection for hit + // testing, position mapping, and selection rectangle computation. + + extension View { + func overlayTextLayoutCollection( + @ViewBuilder content: @escaping (any TextLayoutCollection) -> some View + ) -> some View { + overlayPreferenceValue(Text.LayoutKey.self) { value in + GeometryReader { geometry in + content(LiveTextLayoutCollection(base: value, geometry: geometry)) + } + } + } + } +#endif diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextLinkInteraction.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextLinkInteraction.swift new file mode 100644 index 000000000..4db5ea65c --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextLinkInteraction.swift @@ -0,0 +1,56 @@ +import SwiftUI + +// MARK: - Overview +// +// `TextLinkInteraction` adds lightweight link tapping to a `Text` fragment. +// +// SwiftUI resolves a `Text.Layout` for each fragment and publishes it through the `Text.LayoutKey` +// preference. This modifier reads the anchored layout, converts tap locations to layout-local +// coordinates, and looks for the first run whose typographic bounds contains the tap. When a run +// has a `url`, the modifier invokes the environment’s `openURL` action. + +struct TextLinkInteraction: ViewModifier { + @Environment(\.openURL) private var openURL + + func body(content: Content) -> some View { + #if TEXTUAL_ENABLE_LINKS + content + .overlayPreferenceValue(Text.LayoutKey.self) { value in + if let anchoredLayout = value.first { + GeometryReader { geometry in + Color.clear + .contentShape(.rect) + .gesture( + tap( + origin: geometry[anchoredLayout.origin], + layout: anchoredLayout.layout + ) + ) + } + } + } + #else + content + #endif + } + + #if TEXTUAL_ENABLE_LINKS + private func tap(origin: CGPoint, layout: Text.Layout) -> some Gesture { + SpatialTapGesture() + .onEnded { value in + let localPoint = CGPoint( + x: value.location.x - origin.x, + y: value.location.y - origin.y + ) + let runs = layout.flatMap(\.self) + let run = runs.first { run in + run.typographicBounds.rect.contains(localPoint) + } + guard let url = run?.url else { + return + } + openURL(url) + } + } + #endif +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextPosition.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextPosition.swift new file mode 100644 index 000000000..218e63f79 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextPosition.swift @@ -0,0 +1,39 @@ +#if TEXTUAL_ENABLE_TEXT_SELECTION + import Foundation + + // MARK: - Overview + // + // `TextPosition` identifies a caret position inside the resolved text layout. + // + // Positions are expressed as an `IndexPath` into the layout tree (layout → line → run → slice). + // `Affinity` disambiguates positions that sit exactly on a boundary, like the end of a line or + // the edge between two run slices. That extra bit of information makes range comparisons and + // containment behave consistently when the same visual location can map to two adjacent indices. + + struct TextPosition: Hashable, Comparable, CustomStringConvertible { + enum Affinity: Comparable { + case downstream // leading edge in the current layout direction + case upstream // trailing edge + } + + let indexPath: IndexPath + let affinity: Affinity + + var description: String { + let path = "(\(indexPath.map(\.description).joined(separator: ", ")))" + switch affinity { + case .downstream: + return "^\(path)" + case .upstream: + return "\(path)^" + } + } + + static func < (lhs: TextPosition, rhs: TextPosition) -> Bool { + if lhs.indexPath == rhs.indexPath { + return lhs.affinity < rhs.affinity + } + return lhs.indexPath < rhs.indexPath + } + } +#endif diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextRange.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextRange.swift new file mode 100644 index 000000000..2b0c0c78b --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextRange.swift @@ -0,0 +1,48 @@ +#if TEXTUAL_ENABLE_TEXT_SELECTION + import Foundation + + // MARK: - Overview + // + // `TextRange` represents a half-open selection range in terms of `TextPosition` values. + // + // The range uses `TextPosition.Affinity` to decide whether boundary positions are included. + // That’s what allows ranges like “caret at the end of a slice” to either include or exclude the + // slice, depending on whether the caret is considered to be on the leading or trailing edge. + + struct TextRange: Hashable, CustomStringConvertible { + let start: TextPosition + let end: TextPosition + + var description: String { + "[\(start)...\(end)]" + } + + var isCollapsed: Bool { + start == end + } + + init(from: TextPosition, to: TextPosition) { + if from <= to { + self.init(start: from, end: to) + } else { + self.init(start: to, end: from) + } + } + + init(start: TextPosition, end: TextPosition) { + assert(start <= end) + + self.start = start + self.end = end + } + + func contains(_ position: TextPosition) -> Bool { + (start.affinity == .upstream + ? position > start + : position >= start) + && (end.affinity == .downstream + ? position < end + : position <= end) + } + } +#endif diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextSelectionBackground.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextSelectionBackground.swift new file mode 100644 index 000000000..37f3698ec --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextSelectionBackground.swift @@ -0,0 +1,29 @@ +import SwiftUI + +// MARK: - Overview +// +// `TextSelectionBackground` draws selection highlights behind a `Text` fragment. +// +// The platform selection interaction stores a `TextSelectionModel` in the environment at fragment +// scope. This modifier reads the fragment’s anchored `Text.Layout` and forwards it to the AppKit +// selection view so it can convert the current selected range into highlight rectangles. + +struct TextSelectionBackground: ViewModifier { + func body(content: Content) -> some View { + #if TEXTUAL_ENABLE_TEXT_SELECTION && canImport(AppKit) && !targetEnvironment(macCatalyst) + content + .backgroundPreferenceValue(Text.LayoutKey.self) { value in + if let anchoredLayout = value.first { + GeometryReader { geometry in + AppKitTextSelectionView( + layout: anchoredLayout.layout, + origin: geometry[anchoredLayout.origin] + ) + } + } + } + #else + content + #endif + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextSelectionCoordinator.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextSelectionCoordinator.swift new file mode 100644 index 000000000..493043749 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextSelectionCoordinator.swift @@ -0,0 +1,53 @@ +import SwiftUI + +// MARK: - Overview +// +// `TextSelectionCoordinator` ensures there’s at most one active selection across a view subtree. +// +// Selection can be driven by multiple independent overlays. For example, `Overflow`-backed +// scrollable regions install their own interaction views so selection works locally inside the +// scroll view, while the surrounding content can still be selectable. +// +// Each `TextSelectionModel` registers with a shared coordinator. When one model becomes selected, +// the coordinator clears selection in the others, preventing multiple active selections across +// local and non-scrollable regions. + +#if TEXTUAL_ENABLE_TEXT_SELECTION + @Observable + final class TextSelectionCoordinator { + private var models: [WeakBox] = [] + + func register(_ model: TextSelectionModel) { + models.append(WeakBox(model)) + compact() + } + + func modelDidSelectText(_ model: TextSelectionModel) { + // Clear selection in the other models + for weakModel in models where weakModel.wrapped !== model { + weakModel.wrapped?.selectedRange = nil + } + compact() + } + + private func compact() { + models.removeAll { + $0.wrapped == nil + } + } + } +#endif + +struct TextSelectionCoordination: ViewModifier { + #if TEXTUAL_ENABLE_TEXT_SELECTION + @State private var coordinator = TextSelectionCoordinator() + #endif + + func body(content: Content) -> some View { + #if TEXTUAL_ENABLE_TEXT_SELECTION + content.environment(coordinator) + #else + content + #endif + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextSelectionInteraction.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextSelectionInteraction.swift new file mode 100644 index 000000000..6fe2eb27c --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextSelectionInteraction.swift @@ -0,0 +1,50 @@ +import SwiftUI + +// MARK: - Overview +// +// `TextSelectionInteraction` manages the text selection model lifecycle for multiple `Text` fragments. +// +// Selection is opt-in through the `textSelection` environment value. When enabled, the modifier +// observes text layout changes via `overlayTextLayoutCollection` and creates or updates a +// `TextSelectionModel`. The model is then passed to the platform-specific implementation +// (`PlatformTextSelectionInteraction`), which presents the appropriate selection UI for macOS +// or iOS. This separation keeps model management in shared code while platform interactions +// remain independent. + +struct TextSelectionInteraction: ViewModifier { + #if TEXTUAL_ENABLE_TEXT_SELECTION + @Environment(\.textSelection) private var textSelection + @Environment(TextSelectionCoordinator.self) private var coordinator: TextSelectionCoordinator? + + @State private var model = TextSelectionModel() + #endif + + func body(content: Content) -> some View { + #if TEXTUAL_ENABLE_TEXT_SELECTION + if textSelection.allowsSelection { + content + .overlayTextLayoutCollection { layoutCollection in + Color.clear + .onChange(of: AnyTextLayoutCollection(layoutCollection), initial: true) { + model.setCoordinator(coordinator) + model.setLayoutCollection(layoutCollection) + } + } + .modifier(PlatformTextSelectionInteraction(model: model)) + } else { + content + } + #else + content + #endif + } +} + +#if TEXTUAL_ENABLE_TEXT_SELECTION + extension EnvironmentValues { + @available(tvOS, unavailable) + @available(watchOS, unavailable) + @usableFromInline + @Entry var textSelection: any TextSelectability.Type = DisabledTextSelectability.self + } +#endif diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextSelectionModel.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextSelectionModel.swift new file mode 100644 index 000000000..b7baae001 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextSelectionModel.swift @@ -0,0 +1,193 @@ +#if TEXTUAL_ENABLE_TEXT_SELECTION + import SwiftUI + + // MARK: - Overview + // + // `TextSelectionModel` is the shared state object that backs selection and interaction. + // + // Platform views (AppKit/UIKit) mutate `selectedRange` in response to gestures and editing + // commands. The model delegates layout-specific work to a `TextLayoutCollection`, which can be + // rebuilt at any time as SwiftUI resolves new `Text.Layout` values. When the layout collection + // changes, the model attempts to reconcile the current selection into the new layout so the + // selection stays stable across updates. + + @Observable + final class TextSelectionModel { + var selectedRange: TextRange? { + willSet { + selectionWillChange?() + } + didSet { + if selectedRange != nil { + coordinator?.modelDidSelectText(self) + } + selectionDidChange?() + } + } + + @ObservationIgnored + var selectionWillChange: (() -> Void)? + + @ObservationIgnored + var selectionDidChange: (() -> Void)? + + @ObservationIgnored + private var layoutCollection: any TextLayoutCollection + + @ObservationIgnored + private weak var coordinator: TextSelectionCoordinator? + + init( + layoutCollection: any TextLayoutCollection = EmptyTextLayoutCollection(), + coordinator: TextSelectionCoordinator? = nil + ) { + self.layoutCollection = layoutCollection + setCoordinator(coordinator) + } + + func setLayoutCollection(_ layoutCollection: any TextLayoutCollection) { + guard !layoutCollection.isEqual(to: self.layoutCollection) else { + return + } + + let oldLayoutCollection = self.layoutCollection + self.layoutCollection = layoutCollection + + guard + let selectedRange, + layoutCollection.needsPositionReconciliation(with: oldLayoutCollection) + else { + return + } + + // Try to reconcile the selected text range + self.selectedRange = layoutCollection.reconcileRange( + selectedRange, + from: oldLayoutCollection + ) + } + + func setCoordinator(_ coordinator: TextSelectionCoordinator?) { + if self.coordinator === coordinator { + return + } + + self.coordinator = coordinator + coordinator?.register(self) + } + + func url(for point: CGPoint) -> URL? { + layoutCollection.url(for: point) + } + + func layoutIndex(of layout: Text.Layout) -> Int? { + layoutCollection.index(of: layout) + } + } + + extension TextSelectionModel { + var hasText: Bool { + layoutCollection.stringLength > 0 + } + + var startPosition: TextPosition { + layoutCollection.startPosition + } + + var endPosition: TextPosition { + layoutCollection.endPosition + } + + func attributedText(in range: TextRange) -> NSAttributedString { + layoutCollection.attributedText(in: range) + } + + func text(in range: TextRange) -> String { + attributedText(in: range).string + } + + func position(from position: TextPosition, offset: Int) -> TextPosition? { + layoutCollection.position(from: position, offset: offset) + } + + func offset(from: TextPosition, to: TextPosition) -> Int { + layoutCollection.characterIndex(at: to) - layoutCollection.characterIndex(at: from) + } + + func firstRect(for range: TextRange) -> CGRect { + layoutCollection.firstRect(for: range) + } + + func caretRect(for position: TextPosition) -> CGRect { + layoutCollection.caretRect(for: position) + } + + func selectionRects(for range: TextRange) -> [TextSelectionRect] { + layoutCollection.selectionRects(for: range) + } + + func selectionRects(for range: TextRange, layout: Text.Layout) -> [TextSelectionRect] { + layoutCollection.selectionRects(for: range, layout: layout) + } + + func closestPosition(to point: CGPoint) -> TextPosition? { + layoutCollection.closestPosition(to: point) + } + + func closestPosition(to point: CGPoint, within range: TextRange) -> TextPosition? { + guard let position = closestPosition(to: point) else { return nil } + if position <= range.start { return range.start } + if position >= range.end { return range.end } + return position + } + + func isPositionAtBlockBoundary(_ position: TextPosition) -> Bool { + layoutCollection.isPositionAtBlockBoundary(position) + } + + func positionAbove(_ position: TextPosition, anchor: TextPosition) -> TextPosition? { + layoutCollection.positionAbove(position, anchor: anchor) + } + + func positionBelow(_ position: TextPosition, anchor: TextPosition) -> TextPosition? { + layoutCollection.positionBelow(position, anchor: anchor) + } + + func characterRange(at point: CGPoint) -> TextRange? { + layoutCollection.characterRange(at: point) + } + + func blockStart(for position: TextPosition) -> TextPosition? { + layoutCollection.blockStart(for: position) + } + + func blockEnd(for position: TextPosition) -> TextPosition? { + layoutCollection.blockEnd(for: position) + } + + func blockRange(for position: TextPosition) -> TextRange? { + layoutCollection.blockRange(for: position) + } + + @available(macOS 10.0, *) + @available(iOS, unavailable) + @available(visionOS, unavailable) + func wordRange(for position: TextPosition) -> TextRange? { + layoutCollection.wordRange(for: position) + } + + @available(macOS 10.0, *) + @available(iOS, unavailable) + @available(visionOS, unavailable) + func nextWord(from position: TextPosition) -> TextPosition? { + layoutCollection.nextWord(from: position) + } + + @available(macOS 10.0, *) + @available(iOS, unavailable) + @available(visionOS, unavailable) + func previousWord(from position: TextPosition) -> TextPosition? { + layoutCollection.previousWord(from: position) + } + } +#endif diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextSelectionRect.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextSelectionRect.swift new file mode 100644 index 000000000..2361be319 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/Shared/TextSelectionRect.swift @@ -0,0 +1,36 @@ +#if TEXTUAL_ENABLE_TEXT_SELECTION + import SwiftUI + + struct TextSelectionRect: Hashable, CustomStringConvertible { + var rect: CGRect + + let layoutDirection: LayoutDirection + var containsStart: Bool + var containsEnd: Bool + + var description: String { + "(\(rect.logDescription), \(layoutDirection == .leftToRight ? "LTR" : "RTL"))" + } + + init( + rect: CGRect, + layoutDirection: LayoutDirection, + containsStart: Bool = false, + containsEnd: Bool = false + ) { + self.rect = rect + self.layoutDirection = layoutDirection + self.containsStart = containsStart + self.containsEnd = containsEnd + } + + func offsetBy(dx: CGFloat, dy: CGFloat) -> TextSelectionRect { + .init( + rect: rect.offsetBy(dx: dx, dy: dy), + layoutDirection: layoutDirection, + containsStart: containsStart, + containsEnd: containsEnd + ) + } + } +#endif diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/UIKit/TextActivityItemSource.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/UIKit/TextActivityItemSource.swift new file mode 100644 index 000000000..99ec4cc0b --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/UIKit/TextActivityItemSource.swift @@ -0,0 +1,42 @@ +#if TEXTUAL_ENABLE_TEXT_SELECTION && canImport(UIKit) + import UIKit + import LinkPresentation + + final class TextActivityItemSource: NSObject, UIActivityItemSource { + let transferableText: TransferableText + + init(attributedString: NSAttributedString) { + self.transferableText = TransferableText(attributedString: attributedString) + super.init() + } + + func activityViewControllerPlaceholderItem( + _ activityViewController: UIActivityViewController + ) -> Any { + transferableText.attributedString.string + } + + func activityViewController( + _ activityViewController: UIActivityViewController, + itemForActivityType activityType: UIActivity.ActivityType? + ) -> Any? { + NSItemProvider(object: transferableText) + } + + func activityViewControllerLinkMetadata(_ activityViewController: UIActivityViewController) + -> LPLinkMetadata? + { + let metadata = LPLinkMetadata() + metadata.title = transferableText.attributedString.string + + if let icon = UIImage(systemName: "character.cursor.ibeam")? + .applyingSymbolConfiguration(.init(hierarchicalColor: .label))? + .withRenderingMode(.alwaysOriginal) + { + metadata.iconProvider = NSItemProvider(object: icon) + } + + return metadata + } + } +#endif diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/UIKit/TextPositionBox.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/UIKit/TextPositionBox.swift new file mode 100644 index 000000000..14897d938 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/UIKit/TextPositionBox.swift @@ -0,0 +1,15 @@ +#if TEXTUAL_ENABLE_TEXT_SELECTION && canImport(UIKit) + import UIKit + + final class TextPositionBox: UITextPosition { + let wrappedValue: TextPosition + + override var description: String { + wrappedValue.description + } + + init(_ wrappedValue: TextPosition) { + self.wrappedValue = wrappedValue + } + } +#endif diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/UIKit/TextRangeBox.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/UIKit/TextRangeBox.swift new file mode 100644 index 000000000..a11cdfe6c --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/UIKit/TextRangeBox.swift @@ -0,0 +1,57 @@ +#if TEXTUAL_ENABLE_TEXT_SELECTION && canImport(UIKit) + import UIKit + + final class TextRangeBox: UITextRange { + private let _start: TextPositionBox + private let _end: TextPositionBox + + var wrappedValue: TextRange { + .init(start: _start.wrappedValue, end: _end.wrappedValue) + } + + var wrappedStart: TextPosition { + _start.wrappedValue + } + + var wrappedEnd: TextPosition { + _end.wrappedValue + } + + override var start: UITextPosition { _start } + override var end: UITextPosition { _end } + + override var description: String { + "[\(_start)...\(_end)]" + } + + override var isEmpty: Bool { + _start.wrappedValue == _end.wrappedValue + } + + init(start: TextPositionBox, end: TextPositionBox) { + assert( + start.wrappedValue <= end.wrappedValue, + "[UITextRangeWrapper] start position must be <= end position" + ) + + self._start = start + self._end = end + } + + convenience init(from: TextPositionBox, to: TextPositionBox) { + if from.wrappedValue <= to.wrappedValue { + self.init(start: from, end: to) + } else { + self.init(start: to, end: from) + } + } + + convenience init(position: TextPositionBox) { + self.init(start: position, end: position) + } + + convenience init(_ range: TextRange) { + self.init(start: .init(range.start), end: .init(range.end)) + } + } +#endif diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/UIKit/TextSelectionRectBox.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/UIKit/TextSelectionRectBox.swift new file mode 100644 index 000000000..d65f07cf4 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/UIKit/TextSelectionRectBox.swift @@ -0,0 +1,30 @@ +#if TEXTUAL_ENABLE_TEXT_SELECTION && canImport(UIKit) + import UIKit + + final class TextSelectionRectBox: UITextSelectionRect { + private let wrappedValue: TextSelectionRect + + override var description: String { + wrappedValue.description + } + + init(_ wrappedValue: TextSelectionRect) { + self.wrappedValue = wrappedValue + } + + override var rect: CGRect { wrappedValue.rect } + override var writingDirection: NSWritingDirection { + switch wrappedValue.layoutDirection { + case .leftToRight: + .leftToRight + case .rightToLeft: + .rightToLeft + @unknown default: + .natural + } + } + override var containsStart: Bool { wrappedValue.containsStart } + override var containsEnd: Bool { wrappedValue.containsEnd } + override var isVertical: Bool { false } // Not applicable for our use case + } +#endif diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/UIKit/UIKitTextInteractionOverlay.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/UIKit/UIKitTextInteractionOverlay.swift new file mode 100644 index 000000000..e1f191238 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/UIKit/UIKitTextInteractionOverlay.swift @@ -0,0 +1,36 @@ +#if TEXTUAL_ENABLE_TEXT_SELECTION && canImport(UIKit) + import SwiftUI + + // MARK: - Overview + // + // `UIKitTextInteractionOverlay` bridges the shared `TextSelectionModel` into a `UIView`. + // + // The overlay reads exclusion rectangles for hit-testing. This allows embedded scrollable regions (like + // code blocks) to receive touch events while the parent handles text selection. The view hosts + // a `UITextInteraction` configured for selection and implements the `UITextInput` surface that + // UIKit uses for selection behavior. + + struct UIKitTextInteractionOverlay: UIViewRepresentable { + private let model: TextSelectionModel + private let overflowFrames: [CGRect] + + init(model: TextSelectionModel, overflowFrames: [CGRect]) { + self.model = model + self.overflowFrames = overflowFrames + } + + func makeUIView(context: Context) -> UITextInteractionView { + UITextInteractionView( + model: model, + exclusionRects: overflowFrames, + openURL: context.environment.openURL + ) + } + + func updateUIView(_ uiView: UITextInteractionView, context: Context) { + uiView.model = model + uiView.exclusionRects = overflowFrames + uiView.openURL = context.environment.openURL + } + } +#endif diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/UIKit/UIKitTextSelectionInteraction.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/UIKit/UIKitTextSelectionInteraction.swift new file mode 100644 index 000000000..03313f211 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/UIKit/UIKitTextSelectionInteraction.swift @@ -0,0 +1,28 @@ +#if TEXTUAL_ENABLE_TEXT_SELECTION && canImport(UIKit) + import SwiftUI + + // MARK: - Overview + // + // `UIKitTextSelectionInteraction` presents the platform-specific text selection overlay for iOS. + // + // The modifier receives a `TextSelectionModel` from `TextSelectionInteraction` and overlays + // `UIKitTextInteractionOverlay`, which wraps a `UIView` that handles selection gestures and + // integrates with system edit actions (copy/share). SwiftUI continues to render the text while + // UIKit manages the selection interaction. + + typealias PlatformTextSelectionInteraction = UIKitTextSelectionInteraction + + struct UIKitTextSelectionInteraction: ViewModifier { + private let model: TextSelectionModel + + init(model: TextSelectionModel) { + self.model = model + } + + func body(content: Content) -> some View { + content.overlayPreferenceValue(OverflowFrameKey.self) { frames in + UIKitTextInteractionOverlay(model: model, overflowFrames: frames) + } + } + } +#endif diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/UIKit/UITextInteractionView+UITextInput.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/UIKit/UITextInteractionView+UITextInput.swift new file mode 100644 index 000000000..1d9cfb772 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/UIKit/UITextInteractionView+UITextInput.swift @@ -0,0 +1,185 @@ +#if TEXTUAL_ENABLE_TEXT_SELECTION && canImport(UIKit) + import SwiftUI + + extension UITextInteractionView: UITextInput { + var hasText: Bool { + model.hasText + } + + func insertText(_: String) { + // Do nothing + } + + func deleteBackward() { + // Do nothing + } + + func text(in range: UITextRange) -> String? { + guard let rangeBox = range as? TextRangeBox else { return nil } + return model.text(in: rangeBox.wrappedValue) + } + + func replace(_ range: UITextRange, withText text: String) { + // Do nothing + } + + var selectedTextRange: UITextRange? { + get { model.selectedRange.map(TextRangeBox.init) } + set { + let rangeBox = newValue as? TextRangeBox + model.selectedRange = rangeBox?.wrappedValue + logger.debug("selectedTextRange = \(newValue)") + } + } + + var markedTextRange: UITextRange? { + nil + } + + var markedTextStyle: [NSAttributedString.Key: Any]? { + get { nil } + set {} + } + + func setMarkedText(_ markedText: String?, selectedRange: NSRange) { + // Do nothing + } + + func unmarkText() { + // Do nothing + } + + var beginningOfDocument: UITextPosition { + TextPositionBox(model.startPosition) + } + + var endOfDocument: UITextPosition { + TextPositionBox(model.endPosition) + } + + func textRange( + from fromPosition: UITextPosition, + to toPosition: UITextPosition + ) -> UITextRange? { + guard + let from = fromPosition as? TextPositionBox, + let to = toPosition as? TextPositionBox + else { + return nil + } + return TextRangeBox(from: from, to: to) + } + + func position(from position: UITextPosition, offset: Int) -> UITextPosition? { + guard let positionBox = position as? TextPositionBox else { return nil } + return model.position( + from: positionBox.wrappedValue, + offset: offset + ).map(TextPositionBox.init) + } + + func position( + from position: UITextPosition, + in direction: UITextLayoutDirection, + offset: Int + ) -> UITextPosition? { + logger.error("position(from:in:offset:) is not implemented") + return nil + } + + func compare(_ position: UITextPosition, to other: UITextPosition) -> ComparisonResult { + guard + let lhs = position as? TextPositionBox, let rhs = other as? TextPositionBox, + lhs.wrappedValue != rhs.wrappedValue + else { + return .orderedSame + } + return lhs.wrappedValue < rhs.wrappedValue ? .orderedAscending : .orderedDescending + } + + func offset(from fromPosition: UITextPosition, to toPosition: UITextPosition) -> Int { + guard + let from = fromPosition as? TextPositionBox, + let to = toPosition as? TextPositionBox + else { return 0 } + return model.offset(from: from.wrappedValue, to: to.wrappedValue) + } + + var tokenizer: any UITextInputTokenizer { + self + } + + func position( + within range: UITextRange, + farthestIn direction: UITextLayoutDirection + ) -> UITextPosition? { + logger.error("position(within:farthestIn:) is not implemented") + return nil + } + + func characterRange( + byExtending position: UITextPosition, + in direction: UITextLayoutDirection + ) -> UITextRange? { + logger.error("characterRange(byExtending:in:) is not implemented") + return nil + } + + func baseWritingDirection( + for position: UITextPosition, + in direction: UITextStorageDirection + ) -> NSWritingDirection { + // Not applicable for non-editable interaction mode? + return .natural + } + + func setBaseWritingDirection(_: NSWritingDirection, for _: UITextRange) { + // Do nothing + } + + func firstRect(for range: UITextRange) -> CGRect { + guard let rangeBox = range as? TextRangeBox else { return .zero } + return model.firstRect(for: rangeBox.wrappedValue) + } + + func caretRect(for position: UITextPosition) -> CGRect { + guard let positionBox = position as? TextPositionBox else { return .zero } + return model.caretRect(for: positionBox.wrappedValue) + } + + func selectionRects(for range: UITextRange) -> [UITextSelectionRect] { + guard let rangeBox = range as? TextRangeBox else { return [] } + return model.selectionRects(for: rangeBox.wrappedValue) + .map(TextSelectionRectBox.init) + } + + func closestPosition(to point: CGPoint) -> UITextPosition? { + model.closestPosition(to: point).map(TextPositionBox.init) + } + + func closestPosition(to point: CGPoint, within range: UITextRange) -> UITextPosition? { + guard let rangeBox = range as? TextRangeBox else { return nil } + return model.closestPosition( + to: point, + within: rangeBox.wrappedValue + ).map(TextPositionBox.init) + } + + func characterRange(at point: CGPoint) -> UITextRange? { + model.characterRange(at: point).map(TextRangeBox.init) + } + + var textInputView: UIView { + self + } + + var isEditable: Bool { + false + } + + func attributedText(in range: UITextRange) -> NSAttributedString { + guard let rangeBox = range as? TextRangeBox else { return .init() } + return model.attributedText(in: rangeBox.wrappedValue) + } + } +#endif diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/UIKit/UITextInteractionView+UITextInputTokenizer.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/UIKit/UITextInteractionView+UITextInputTokenizer.swift new file mode 100644 index 000000000..59ffbbad4 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/UIKit/UITextInteractionView+UITextInputTokenizer.swift @@ -0,0 +1,175 @@ +#if TEXTUAL_ENABLE_TEXT_SELECTION && canImport(UIKit) + import SwiftUI + + // MARK: - Overview + // + // We implement `UITextInputTokenizer` so word and sentence selection respects block boundaries. + // + // UIKit’s `UITextInputStringTokenizer` doesn’t understand that runs in different presentation + // intents (paragraphs, list items, code blocks, and so on) are semantically separate. Without a + // custom tokenizer, gestures like double-tap can select across blocks—for example, selecting a + // word at the start of a paragraph and accidentally including text from the previous block. + // + // By clamping word and sentence operations to the current block range reported by the model, the + // selection behavior matches the document’s structure. + + extension UITextInteractionView: UITextInputTokenizer { + func rangeEnclosingPosition( + _ position: UITextPosition, + with granularity: UITextGranularity, + inDirection direction: UITextDirection + ) -> UITextRange? { + guard granularity == .word || granularity == .sentence else { + return _tokenizer.rangeEnclosingPosition( + position, + with: granularity, + inDirection: direction + ) + } + + guard + let range = _tokenizer.rangeEnclosingPosition( + position, + with: granularity, + inDirection: direction + ), + let rangeBox = range as? TextRangeBox + else { + return nil + } + + guard let positionBox = position as? TextPositionBox else { + return range + } + + let rawStart = rangeBox.wrappedStart + let rawEnd = rangeBox.wrappedEnd + let rawPosition = positionBox.wrappedValue + + guard let blockRange = model.blockRange(for: rawPosition) else { + return range + } + + // Clamp the range to stay within the layout boundaries + let clampedRange = TextRange( + start: max(rawStart, blockRange.start), + end: min(rawEnd, blockRange.end) + ) + + return TextRangeBox(clampedRange) + } + + func isPosition( + _ position: UITextPosition, + atBoundary granularity: UITextGranularity, + inDirection direction: UITextDirection + ) -> Bool { + guard + granularity == .word || granularity == .sentence, + let positionBox = position as? TextPositionBox + else { + return _tokenizer.isPosition(position, atBoundary: granularity, inDirection: direction) + } + + let rawPosition = positionBox.wrappedValue + + if model.isPositionAtBlockBoundary(rawPosition) { + return true + } + + return _tokenizer.isPosition(position, atBoundary: granularity, inDirection: direction) + } + + func position( + from position: UITextPosition, + toBoundary granularity: UITextGranularity, + inDirection direction: UITextDirection + ) -> UITextPosition? { + guard granularity == .word || granularity == .sentence else { + return _tokenizer.position(from: position, toBoundary: granularity, inDirection: direction) + } + + // Get the boundary position from the system tokenizer + guard + let boundaryPosition = _tokenizer.position( + from: position, + toBoundary: granularity, + inDirection: direction + ), + let boundaryPositionBox = boundaryPosition as? TextPositionBox + else { + return nil + } + + guard let positionBox = position as? TextPositionBox else { + return boundaryPosition + } + let rawStart = positionBox.wrappedValue + let rawEnd = boundaryPositionBox.wrappedValue + + if rawStart.indexPath.layout != rawEnd.indexPath.layout { + // If they're in different layouts, we need to clamp to the layout boundary + switch direction { + case .storage(.forward): + // Stop at the end of the current layout + if let layoutEnd = model.blockEnd(for: rawStart) { + return TextPositionBox(layoutEnd) + } + case .storage(.backward): + // Stop at the start of the current layout + return TextPositionBox( + .init( + indexPath: .init( + layout: rawStart.indexPath.layout + ), + affinity: .downstream + ) + ) + default: + break + } + } + + return boundaryPosition + } + + func isPosition( + _ position: UITextPosition, + withinTextUnit granularity: UITextGranularity, + inDirection direction: UITextDirection + ) -> Bool { + guard + granularity == .word || granularity == .sentence, + let positionBox = position as? TextPositionBox + else { + return _tokenizer.isPosition(position, withinTextUnit: granularity, inDirection: direction) + } + + let rawPosition = positionBox.wrappedValue + + // If we're at a block boundary, extending in the direction + // that crosses the boundary would leave the text unit + if model.isPositionAtBlockBoundary(rawPosition) { + // At start and moving backward, would cross boundary + if case .storage(.backward) = direction, + rawPosition + == TextPosition( + indexPath: .init(layout: rawPosition.indexPath.layout), + affinity: .downstream + ) + { + return false + } + + // At end and moving forward, would cross boundary + if case .storage(.forward) = direction, + rawPosition == model.blockEnd(for: rawPosition) + { + return false + } + } + + return _tokenizer.isPosition(position, withinTextUnit: granularity, inDirection: direction) + } + } +#endif diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/UIKit/UITextInteractionView.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/UIKit/UITextInteractionView.swift new file mode 100644 index 000000000..50d4205f3 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/Internal/TextInteraction/UIKit/UITextInteractionView.swift @@ -0,0 +1,167 @@ +#if TEXTUAL_ENABLE_TEXT_SELECTION && canImport(UIKit) + import SwiftUI + import os + import UniformTypeIdentifiers + + // MARK: - Overview + // + // `UITextInteractionView` implements selection and link interaction on iOS-family platforms. + // + // The view sits in an overlay above one or more rendered `Text` fragments. It uses + // `TextSelectionModel` to translate touch locations into URLs and selection ranges, and it + // respects `exclusionRects` so embedded scrollable regions can continue to handle gestures. + // Selection UI is provided by `UITextInteraction` configured for non-editable content. + + final class UITextInteractionView: UIView { + override var canBecomeFirstResponder: Bool { + true + } + + var model: TextSelectionModel + var exclusionRects: [CGRect] + var openURL: OpenURLAction + + weak var inputDelegate: (any UITextInputDelegate)? + + let logger = Logger(category: .textInteraction) + + private(set) lazy var _tokenizer = UITextInputStringTokenizer(textInput: self) + private let selectionInteraction: UITextInteraction + + init( + model: TextSelectionModel, + exclusionRects: [CGRect], + openURL: OpenURLAction + ) { + self.model = model + self.exclusionRects = exclusionRects + self.openURL = openURL + self.selectionInteraction = UITextInteraction(for: .nonEditable) + + super.init(frame: .zero) + self.backgroundColor = .clear + + setUp() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + for exclusionRect in exclusionRects { + if exclusionRect.contains(point) { + return false + } + } + return super.point(inside: point, with: event) + } + + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + switch action { + case #selector(copy(_:)), #selector(share(_:)): + return !(model.selectedRange?.isCollapsed ?? true) + default: + return false + } + } + + override func copy(_ sender: Any?) { + guard let selectedRange = model.selectedRange else { + return + } + + let attributedText = model.attributedText(in: selectedRange) + let formatter = Formatter(attributedText) + + UIPasteboard.general.setItems( + [ + [ + UTType.utf8PlainText.identifier: Data(formatter.plainText().utf8), + UTType.html.identifier: formatter.html(), + ] + ] + ) + } + + private func setUp() { + model.selectionWillChange = { [weak self] in + guard let self else { return } + self.inputDelegate?.selectionWillChange(self) + } + model.selectionDidChange = { [weak self] in + guard let self else { return } + self.inputDelegate?.selectionDidChange(self) + } + + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) + addGestureRecognizer(tapGesture) + + selectionInteraction.textInput = self + selectionInteraction.delegate = self + + for gesture in selectionInteraction.gesturesForFailureRequirements { + tapGesture.require(toFail: gesture) + } + + addInteraction(selectionInteraction) + } + + @objc private func handleTap(_ gesture: UITapGestureRecognizer) { + let location = gesture.location(in: self) + guard let url = model.url(for: location) else { + model.selectedRange = nil + return + } + openURL(url) + } + + @objc private func share(_ sender: Any?) { + guard let selectedRange = model.selectedRange else { + return + } + + let attributedText = model.attributedText(in: selectedRange) + let itemSource = TextActivityItemSource(attributedString: attributedText) + + let activityViewController = UIActivityViewController( + activityItems: [itemSource], + applicationActivities: nil + ) + + if let popover = activityViewController.popoverPresentationController { + let rect = + model.selectionRects(for: selectedRange) + .last?.rect.integral ?? .zero + popover.sourceView = self + popover.sourceRect = rect + } + + if let windowScene = window?.windowScene, + let viewController = windowScene.windows.first?.rootViewController + { + viewController.present(activityViewController, animated: true) + } + } + } + + extension UITextInteractionView: UITextInteractionDelegate { + func interactionShouldBegin(_ interaction: UITextInteraction, at point: CGPoint) -> Bool { + logger.debug("interactionShouldBegin(at: \(point.logDescription)) -> true") + return true + } + + func interactionWillBegin(_ interaction: UITextInteraction) { + logger.debug("interactionWillBegin") + _ = self.becomeFirstResponder() + } + + func interactionDidEnd(_ interaction: UITextInteraction) { + logger.debug("interactionDidEnd") + } + } + + extension Logger.Textual.Category { + fileprivate static let textInteraction = Self(rawValue: "textInteraction") + } +#endif diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/MarkdownParser/AttributedStringMarkdownParser.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/MarkdownParser/AttributedStringMarkdownParser.swift new file mode 100644 index 000000000..b77212a5d --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/MarkdownParser/AttributedStringMarkdownParser.swift @@ -0,0 +1,60 @@ +import Foundation + +/// A ``MarkupParser`` implementation backed by Foundation’s Markdown support. +/// +/// This parser leverages Foundation’s Markdown support and preserves structure via +/// presentation intents. +/// +/// This parser can process its output to expand custom emoji and math expressions into +/// inline attachments. +public struct AttributedStringMarkdownParser: MarkupParser { + private let baseURL: URL? + private let options: AttributedString.MarkdownParsingOptions + private let processor: PatternProcessor + + public init( + baseURL: URL?, + options: AttributedString.MarkdownParsingOptions = .init(), + syntaxExtensions: [SyntaxExtension] = [] + ) { + self.baseURL = baseURL + self.options = options + self.processor = PatternProcessor(syntaxExtensions: syntaxExtensions) + } + + public func attributedString(for input: String) throws -> AttributedString { + try processor.expand( + AttributedString( + markdown: input, + including: \.textual, + options: options, + baseURL: baseURL + ) + ) + } +} + +extension MarkupParser where Self == AttributedStringMarkdownParser { + /// Creates a Markdown parser configured for inline-only syntax. + public static func inlineMarkdown( + baseURL: URL? = nil, + syntaxExtensions: [AttributedStringMarkdownParser.SyntaxExtension] = [] + ) -> Self { + .init( + baseURL: baseURL, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace), + syntaxExtensions: syntaxExtensions + ) + } + + /// Creates a Markdown parser configured for full-document syntax. + public static func markdown( + baseURL: URL? = nil, + syntaxExtensions: [AttributedStringMarkdownParser.SyntaxExtension] = [] + ) -> Self { + .init( + baseURL: baseURL, + syntaxExtensions: syntaxExtensions + ) + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/MarkdownParser/Emoji.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/MarkdownParser/Emoji.swift new file mode 100644 index 000000000..1805b61b2 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/MarkdownParser/Emoji.swift @@ -0,0 +1,20 @@ +import Foundation + +/// A custom emoji definition used during syntax extension expansion after Markdown parsing. +/// +/// You can pass a set of `Emoji` values by adding +/// ``AttributedStringMarkdownParser/SyntaxExtension/emoji(_:)`` to +/// `syntaxExtensions` to expand `:shortcode:` sequences into inline attachments. +public struct Emoji: Hashable, Sendable, Codable { + /// The shortcode used in the markup, without surrounding `:` characters. + public let shortcode: String + + /// The URL to load the emoji image from. + public let url: URL + + /// Creates a custom emoji definition. + public init(shortcode: String, url: URL) { + self.shortcode = shortcode + self.url = url + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/MarkdownParser/SyntaxExtension.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/MarkdownParser/SyntaxExtension.swift new file mode 100644 index 000000000..5289fc41f --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/MarkdownParser/SyntaxExtension.swift @@ -0,0 +1,54 @@ +import Foundation + +extension AttributedStringMarkdownParser { + /// A syntax extension that replaces matched tokens after Markdown parsing. + public struct SyntaxExtension { + let patterns: [PatternTokenizer.Pattern] + let replace: + ( + _ token: PatternTokenizer.Token, + _ attributes: AttributeContainer + ) -> AttributedString? + } +} + +extension AttributedStringMarkdownParser.SyntaxExtension { + /// Replaces `:shortcode:` sequences using the provided custom emoji definitions. + public static func emoji(_ emoji: Set) -> Self { + guard !emoji.isEmpty else { + return Self(patterns: [], replace: { _, _ in nil }) + } + + let emojiMap = Dictionary( + uniqueKeysWithValues: emoji.map { emoji in + (emoji.shortcode, emoji) + } + ) + + return Self(patterns: [.emoji]) { token, attributes in + guard let shortcode = token.capturedContent, let emoji = emojiMap[shortcode] else { + return nil + } + + return AttributedString( + shortcode, + attributes: attributes.emojiURL(emoji.url) + ) + } + } + + /// Replaces inline and block math expressions with attachments. + public static var math: Self { + .init(patterns: [.mathBlock, .mathInline]) { token, attributes in + guard let latex = token.capturedContent else { + return nil + } + + let attachment = MathAttachment( + latex: latex, + style: token.type == .mathBlock ? .block : .inline + ) + return AttributedString("\u{FFFC}", attributes: attributes.attachment(.init(attachment))) + } + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/MarkdownParser/TextualAttributes.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/MarkdownParser/TextualAttributes.swift new file mode 100644 index 000000000..ec9bb41d0 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/MarkdownParser/TextualAttributes.swift @@ -0,0 +1,43 @@ +import SwiftUI + +extension AttributeScopes { + /// Attributes used by Textual when parsing and rendering markup. + public struct TextualAttributes: AttributeScope { + /// Stores an attachment value in attributed content. + public enum AttachmentAttribute: AttributedStringKey { + public typealias Value = AnyAttachment + public static let name = "Textual.Attachment" + } + + /// Stores a URL for a custom emoji placeholder. + /// + /// Textual uses this attribute as an intermediate representation before resolving emoji into + /// an attachment. + public enum EmojiURLAttribute: AttributedStringKey { + public typealias Value = URL + public static let name = "Textual.EmojiURL" + } + + /// A property for accessing an attachment attribute. + public let attachment: AttachmentAttribute + + /// A property for accessing an emoji URL attribute. + public let emojiURL: EmojiURLAttribute + + public let foundation: AttributeScopes.FoundationAttributes + } + + /// The Textual attribute scope. + public var textual: TextualAttributes.Type { + TextualAttributes.self + } +} + +extension AttributeDynamicLookup { + /// Provides dynamic member lookup for Textual attributes. + public subscript( + dynamicMember keyPath: KeyPath + ) -> T { + return self[T.self] + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/MarkupParser.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/MarkupParser.swift new file mode 100644 index 000000000..1a8514133 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/MarkupParser.swift @@ -0,0 +1,38 @@ +import Foundation + +/// Converts markup into attributed content that Textual can render. +/// +/// `InlineText` and `StructuredText` accept a `MarkupParser` to turn an input `String` into an +/// `AttributedString`. The resulting attributed content can include Foundation attributes, along with +/// Textual-specific attributes used for attachments and custom emoji. +/// +/// Textual looks for a small set of well-known attributes when rendering: +/// +/// `PresentationIntentAttribute`, `InlinePresentationIntentAttribute`, +/// `LinkAttribute`, and `ImageURLAttribute`. +/// +/// It also supports its own attributes for carrying resolved attachments and custom emoji URLs: +/// +/// ``Foundation/AttributeScopes/TextualAttributes/AttachmentAttribute`` and +/// ``Foundation/AttributeScopes/TextualAttributes/EmojiURLAttribute``. +/// +/// Textual ships with a Markdown parser out of the box: ``AttributedStringMarkdownParser``. +/// +/// ```swift +/// InlineText("**Hello** _world_", parser: .inlineMarkdown()) +/// ``` +/// +/// If you implement your own parser, keep the output stable and deterministic. Textual may call +/// the parser whenever the input changes. +/// +/// For structured content, make sure your output carries the appropriate presentation intents. +/// Textual groups content into blocks by walking `PresentationIntent` changes across runs, so +/// missing or inconsistent intents will typically show up as incorrect block rendering. +@MainActor +public protocol MarkupParser { + /// Returns attributed content for the given input string. + /// + /// - Parameter input: The markup source string. + /// - Returns: An attributed string representing the parsed markup. + func attributedString(for input: String) throws -> AttributedString +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/MathProperties.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/MathProperties.swift new file mode 100644 index 000000000..b0d2ae9b5 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/MathProperties.swift @@ -0,0 +1,55 @@ +import SwiftUI +private import SwiftUIMath + +/// Properties that control how Textual renders math expressions. +public struct MathProperties: Sendable, Hashable { + public struct FontName: Hashable, Sendable, RawRepresentable, ExpressibleByStringLiteral { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: StringLiteralType) { + self.rawValue = value + } + } + + /// The math font family to use. + public var fontName: FontName + + /// Scales the math font relative to the surrounding text. + public var fontScale: CGFloat + + /// The alignment applied to block math paragraphs. + public var textAlignment: TextAlignment + + public init( + fontName: FontName = .latinModern, + fontScale: CGFloat = 1.2, + textAlignment: TextAlignment = .center + ) { + self.fontName = fontName + self.fontScale = fontScale + self.textAlignment = textAlignment + } +} + +extension MathProperties.FontName { + public static let latinModern: Self = .init(rawValue: Math.Font.Name.latinModern.rawValue) + public static let kpMathLight: Self = .init(rawValue: Math.Font.Name.kpMathLight.rawValue) + public static let kpMathSans: Self = .init(rawValue: Math.Font.Name.kpMathSans.rawValue) + public static let xits: Self = .init(rawValue: Math.Font.Name.xits.rawValue) + public static let termes: Self = .init(rawValue: Math.Font.Name.termes.rawValue) + public static let asana: Self = .init(rawValue: Math.Font.Name.asana.rawValue) + public static let euler: Self = .init(rawValue: Math.Font.Name.euler.rawValue) + public static let fira: Self = .init(rawValue: Math.Font.Name.fira.rawValue) + public static let notoSans: Self = .init(rawValue: Math.Font.Name.notoSans.rawValue) + public static let libertinus: Self = .init(rawValue: Math.Font.Name.libertinus.rawValue) + public static let garamond: Self = .init(rawValue: Math.Font.Name.garamond.rawValue) + public static let leteSans: Self = .init(rawValue: Math.Font.Name.leteSans.rawValue) +} + +extension EnvironmentValues { + @Entry var mathProperties = MathProperties() +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/HighlighterTheme/HighlighterTheme+Default.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/HighlighterTheme/HighlighterTheme+Default.swift new file mode 100644 index 000000000..b5a6dd251 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/HighlighterTheme/HighlighterTheme+Default.swift @@ -0,0 +1,143 @@ +import SwiftUI + +extension StructuredText.HighlighterTheme { + /// The default syntax-highlighting theme used by Textual. + public static let `default` = Self( + foregroundColor: .codePlain, + backgroundColor: .codeBackground, + tokenProperties: [ + // Keywords + .keyword: AnyTextProperty( + .foregroundColor(.codeKeyword), + .fontWeight(.semibold) + ), + .builtin: AnyTextProperty(.foregroundColor(.codeBuiltin)), + .literal: AnyTextProperty( + .foregroundColor(.codeKeyword), + .fontWeight(.semibold) + ), + // Strings and characters + .string: AnyTextProperty(.foregroundColor(.codeString)), + .char: AnyTextProperty(.foregroundColor(.codeChar)), + .regex: AnyTextProperty(.foregroundColor(.codeString)), + .url: AnyTextProperty(.foregroundColor(.codeURL)), + // Numbers and symbols + .number: AnyTextProperty(.foregroundColor(.codeNumber)), + .symbol: AnyTextProperty(.foregroundColor(.codePlain)), + .boolean: AnyTextProperty( + .foregroundColor(.codeKeyword), + .fontWeight(.semibold) + ), + // Types and classes + .className: AnyTextProperty(.foregroundColor(.codeClass)), + // Functions + .function: AnyTextProperty(.foregroundColor(.codeFunction)), + .functionName: AnyTextProperty(.foregroundColor(.codeFunction)), + // Variables and properties + .variable: AnyTextProperty(.foregroundColor(.codeVariable)), + .constant: AnyTextProperty(.foregroundColor(.codeConstant)), + .property: AnyTextProperty(.foregroundColor(.codeVariable)), + // Comments + .comment: AnyTextProperty(.foregroundColor(.codeComment)), + .blockComment: AnyTextProperty(.foregroundColor(.codeComment)), + .docComment: AnyTextProperty(.foregroundColor(.codeComment)), + .mark: AnyTextProperty( + .foregroundColor(.codeMark), + .fontWeight(.bold) + ), + // Preprocessor + .preprocessor: AnyTextProperty(.foregroundColor(.codePreprocessor)), + // Swift + .directive: AnyTextProperty(.foregroundColor(.codePreprocessor)), + .attribute: AnyTextProperty(.foregroundColor(.codeAttribute)), + // Markup + .tag: AnyTextProperty(.foregroundColor(.codeChar)), + .attributeName: AnyTextProperty(.foregroundColor(.codeAttribute)), + // Diff + .inserted: AnyTextProperty(.foregroundColor(.codeInserted)), + .deleted: AnyTextProperty(.foregroundColor(.codeDeleted)), + ] + ) +} + +extension DynamicColor { + fileprivate static let codeKeyword = DynamicColor( + light: Color(red: 0.607592, green: 0.137526, blue: 0.576284), + dark: Color(red: 0.988394, green: 0.37355, blue: 0.638329) + ) + + fileprivate static let codeBuiltin = DynamicColor( + light: Color(red: 0.224543, green: 0, blue: 0.628029), + dark: Color(red: 0.632318, green: 0.402193, blue: 0.901151) + ) + + fileprivate static let codeString = DynamicColor( + light: Color(red: 0.77, green: 0.102, blue: 0.086), + dark: Color(red: 0.989117, green: 0.41558, blue: 0.365684) + ) + + fileprivate static let codeChar = DynamicColor( + light: Color(red: 0.11, green: 0, blue: 0.81), + dark: Color(red: 0.815686, green: 0.74902, blue: 0.411765) + ) + + fileprivate static let codeURL = DynamicColor( + light: Color(red: 0.055, green: 0.055, blue: 1), + dark: Color(red: 0.330191, green: 0.511266, blue: 0.998589) + ) + + fileprivate static let codeNumber = DynamicColor( + light: Color(red: 0.11, green: 0, blue: 0.81), + dark: Color(red: 0.814983, green: 0.749393, blue: 0.412334) + ) + + fileprivate static let codeClass = DynamicColor( + light: Color(red: 0.109812, green: 0.272761, blue: 0.288691), + dark: Color(red: 0.619608, green: 0.945098, blue: 0.866667) + ) + + fileprivate static let codeFunction = DynamicColor( + light: Color(red: 0.194184, green: 0.429349, blue: 0.454553), + dark: Color(red: 0.403922, green: 0.717647, blue: 0.643137) + ) + + fileprivate static let codeVariable = DynamicColor( + light: Color(red: 0.194184, green: 0.429349, blue: 0.454553), + dark: Color(red: 0.405383, green: 0.717051, blue: 0.642088) + ) + + fileprivate static let codeConstant = DynamicColor( + light: Color(red: 0.194184, green: 0.429349, blue: 0.454553), + dark: Color(red: 0.405383, green: 0.717051, blue: 0.642088) + ) + + fileprivate static let codeComment = DynamicColor( + light: Color(red: 0.36526, green: 0.421879, blue: 0.475154), + dark: Color(red: 0.423943, green: 0.474618, blue: 0.525183) + ) + + fileprivate static let codeMark = DynamicColor( + light: Color(red: 0.290196, green: 0.333333, blue: 0.376471), + dark: Color(red: 0.572549, green: 0.631373, blue: 0.694118) + ) + + fileprivate static let codePreprocessor = DynamicColor( + light: Color(red: 0.391471, green: 0.220311, blue: 0.124457), + dark: Color(red: 0.991311, green: 0.560764, blue: 0.246107) + ) + + fileprivate static let codeAttribute = DynamicColor( + light: Color(red: 0.505801, green: 0.371396, blue: 0.012096), + dark: Color(red: 0.74902, green: 0.521569, blue: 0.333333) + ) + + fileprivate static let codeInserted = DynamicColor( + light: Color(red: 0.203922, green: 0.780392, blue: 0.349020), + dark: Color(red: 0.188235, green: 0.819608, blue: 0.345098) + ) + + fileprivate static let codeDeleted = DynamicColor( + light: Color(red: 1, green: 0.219608, blue: 0.235294), + dark: Color(red: 1, green: 0.258824, blue: 0.270588) + ) +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/HighlighterTheme/HighlighterTheme+Plain.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/HighlighterTheme/HighlighterTheme+Plain.swift new file mode 100644 index 000000000..dc4b8715e --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/HighlighterTheme/HighlighterTheme+Plain.swift @@ -0,0 +1,21 @@ +import SwiftUI + +extension StructuredText.HighlighterTheme { + /// A minimal theme that applies only base foreground and background colors. + public static let plain = Self( + foregroundColor: .codePlain, + backgroundColor: .codeBackground + ) +} + +extension DynamicColor { + static let codePlain = DynamicColor( + light: Color(red: 0, green: 0, blue: 0, opacity: 0.85), + dark: Color(red: 1, green: 1, blue: 1, opacity: 0.85) + ) + + static let codeBackground = DynamicColor( + light: Color(red: 0.960784, green: 0.960784, blue: 0.968627), + dark: Color(red: 0.120543, green: 0.122844, blue: 0.141312) + ) +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/HighlighterTheme/HighlighterTheme.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/HighlighterTheme/HighlighterTheme.swift new file mode 100644 index 000000000..2ecf4130f --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/HighlighterTheme/HighlighterTheme.swift @@ -0,0 +1,37 @@ +import SwiftUI + +extension StructuredText { + /// A theme used to style syntax-highlighted code blocks. + /// + /// A `HighlighterTheme` defines the base foreground and background colors for code blocks and + /// an optional set of token-specific text properties. + /// + /// You can set a highlighter theme using the ``TextualNamespace/highlighterTheme(_:)`` modifier. + public struct HighlighterTheme: Hashable, Sendable { + let foregroundColor: DynamicColor + let backgroundColor: DynamicColor + let tokenProperties: [TokenType: AnyTextProperty] + + /// Creates a highlighter theme. + /// + /// - Parameters: + /// - foregroundColor: The default text color for code blocks. + /// - backgroundColor: The background color for code blocks. + /// - tokenProperties: Token-specific text properties. Token types are identified by a + /// string, for example `"keyword"` or `"comment"`. + public init( + foregroundColor: DynamicColor, + backgroundColor: DynamicColor, + tokenProperties: [TokenType: AnyTextProperty] = [:] + ) { + self.foregroundColor = foregroundColor + self.backgroundColor = backgroundColor + self.tokenProperties = tokenProperties + } + } +} + +extension EnvironmentValues { + @usableFromInline + @Entry var highlighterTheme: StructuredText.HighlighterTheme = .default +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/HighlighterTheme/TokenType.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/HighlighterTheme/TokenType.swift new file mode 100644 index 000000000..9b4bdefb9 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/HighlighterTheme/TokenType.swift @@ -0,0 +1,91 @@ +import Foundation + +extension StructuredText.HighlighterTheme { + /// An identifier for a syntax token. + /// + /// Token types are backed by a string so you can match the names produced by the highlighter. + /// You can create them from a raw value or using a string literal. + public struct TokenType: Hashable, RawRepresentable, Sendable, ExpressibleByStringLiteral { + /// The underlying token type string. + public let rawValue: String + + /// Creates a token type from a raw string value. + public init(rawValue: String) { + self.rawValue = rawValue + } + + /// Creates a token type from a string literal. + public init(stringLiteral value: StringLiteralType) { + self.rawValue = value + } + } +} + +extension StructuredText.HighlighterTheme.TokenType { + // General purpose + public static let plain: Self = "plain" + public static let keyword: Self = "keyword" + public static let builtin: Self = "builtin" + public static let className: Self = "class-name" + public static let function: Self = "function" + public static let boolean: Self = "boolean" + public static let number: Self = "number" + public static let string: Self = "string" + public static let char: Self = "char" + public static let symbol: Self = "symbol" + public static let regex: Self = "regex" + public static let url: Self = "url" + public static let `operator`: Self = "operator" + public static let variable: Self = "variable" + public static let constant: Self = "constant" + public static let property: Self = "property" + public static let punctuation: Self = "punctuation" + public static let important: Self = "important" + public static let comment: Self = "comment" + + // Markup + public static let tag: Self = "tag" + public static let attributeName: Self = "attr-name" + public static let attributeValue: Self = "attr-value" + public static let namespace: Self = "namespace" + public static let prolog: Self = "prolog" + public static let doctype: Self = "doctype" + public static let cdata: Self = "cdata" + public static let entity: Self = "entity" + + // Formatting + public static let bold: Self = "bold" + public static let italic: Self = "italic" + + // CSS + public static let atrule: Self = "atrule" + public static let selector: Self = "selector" + + // Diff + public static let inserted: Self = "inserted" + public static let deleted: Self = "deleted" + + // Comments (specialized) + public static let blockComment: Self = "block-comment" + public static let docComment: Self = "doc-comment" + public static let mark: Self = "mark" + + // Functions (specialized) + public static let functionName: Self = "function-name" + + // Preprocessor + public static let preprocessor: Self = "preprocessor" + + // Swift + public static let directive: Self = "directive" + public static let literal: Self = "literal" + public static let otherDirective: Self = "other-directive" + public static let attribute: Self = "attribute" + public static let functionDefinition: Self = "function-definition" + public static let label: Self = "label" + public static let `nil`: Self = "nil" + public static let shortArgument: Self = "short-argument" + public static let omit: Self = "omit" + public static let interpolation: Self = "interpolation" + public static let interpolationPunctuation: Self = "interpolation-punctuation" +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/StructuredText.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/StructuredText.swift new file mode 100644 index 000000000..736e33fc7 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/StructuredText.swift @@ -0,0 +1,230 @@ +import SwiftUI + +/// A view that displays rich, structured text. +/// +/// `StructuredText` renders block elements like paragraphs, headings, lists, block quotes, code +/// blocks, and tables from a markup string. The markup is parsed with a ``MarkupParser`` into an +/// `AttributedString` that Textual can lay out and display. +/// +/// The simplest way to create a `StructuredText` view is to pass Markdown: +/// +/// ```swift +/// let markdown = """ +/// ## Getting Started +/// +/// Before making changes, check a few things: +/// +/// - Skim recent commits +/// - Run the tests +/// - Make your changes +/// +/// Leave a note if something needs attention later. +/// """ +/// +/// var body: some View { +/// StructuredText(markdown: markdown) +/// } +/// ``` +/// +/// ### Customizing Text Appearance +/// +/// `StructuredText` supports standard SwiftUI text modifiers like `.font()`, `.foregroundStyle()`, +/// and `.multilineTextAlignment()`. Note that `.lineLimit()` is explicitly disabled to prevent +/// per-block truncation, which would break the document layout. +/// +/// ```swift +/// StructuredText(markdown: "## Hello\n\nThis is a paragraph.") +/// .font(.callout) +/// .foregroundStyle(.blue) +/// .multilineTextAlignment(.center) +/// ``` +/// +/// ### Styling Structured Text +/// +/// You can apply a full style preset using the ``TextualNamespace/structuredTextStyle(_:)`` modifier. +/// +/// ```swift +/// StructuredText(markdown: markdown) +/// .textual.structuredTextStyle(.gitHub) +/// ``` +/// +/// For more control, you can customize individual block and inline styles. Inline styles +/// apply to spans like emphasis and links. Block styles apply to structural elements: +/// +/// - ``TextualNamespace/headingStyle(_:)``, ``TextualNamespace/paragraphStyle(_:)``, +/// ``TextualNamespace/blockQuoteStyle(_:)``, ``TextualNamespace/thematicBreakStyle(_:)`` +/// - ``TextualNamespace/listItemStyle(_:)``, ``TextualNamespace/unorderedListMarker(_:)``, +/// ``TextualNamespace/orderedListMarker(_:)`` +/// - ``TextualNamespace/codeBlockStyle(_:)``, ``TextualNamespace/highlighterTheme(_:)`` +/// - ``TextualNamespace/tableStyle(_:)``, ``TextualNamespace/tableCellStyle(_:)`` +/// +/// Code blocks and tables may overflow horizontally. You can choose between scrolling and +/// wrapping with ``TextualNamespace/overflowMode(_:)``. +/// +/// ```swift +/// StructuredText(markdown: markdown) +/// .textual.overflowMode(.wrap) +/// ``` +/// +/// ### Interaction +/// +/// When the markup contains links, `StructuredText` uses SwiftUI’s `openURL` environment. Provide a +/// custom `OpenURLAction` to intercept them (for example, to route in-app or to scroll to anchors). +/// +/// You can enable text selection with ``TextualNamespace/textSelection(_:)`` to let users select +/// text in a platform-appropriate way. +/// +/// ```swift +/// StructuredText(markdown: markdown) +/// .environment( +/// \.openURL, +/// OpenURLAction { url in +/// print("Open \(url)") +/// return .handled +/// } +/// ) +/// .textual.textSelection(.enabled) +/// ``` +/// +/// ### Images, links, and relative URLs +/// +/// If your Markdown includes relative image URLs or links, provide a `baseURL`. To render images, +/// configure an attachment loader using the ``TextualNamespace/imageAttachmentLoader(_:)`` +/// modifier. +/// +/// ```swift +/// let baseURL = URL(string: "https://example.com/repo/")! +/// +/// StructuredText(markdown: readme, baseURL: baseURL) +/// .textual.imageAttachmentLoader(.image(relativeTo: baseURL)) +/// ``` +/// +/// When you need to parse something other than Markdown, use ``init(_:parser:)`` with a custom +/// ``MarkupParser`` implementation. +public struct StructuredText: View { + @State private var attributedString = AttributedString() + + private let markup: String + private let parser: any MarkupParser + + /// Creates a structured-text view by parsing `markup` with a custom parser. + /// + /// Use this initializer when you want to provide your own `MarkupParser` implementation. + public init(_ markup: String, parser: any MarkupParser) { + self.markup = markup + self.parser = parser + } + + public var body: some View { + WithAttachments(attributedString) { + BlockContent(content: $0) + .modifier(TextSelectionInteraction()) + .modifier(TextSelectionCoordination()) + } + .coordinateSpace(.textContainer) + .onChange(of: markup, initial: true) { + markupDidChange(markup) + } + // Disable line limit to avoid per-fragment truncation + .lineLimit(nil) + } + + private func markupDidChange(_ markup: String) { + self.attributedString = (try? parser.attributedString(for: markup)) ?? .init() + } +} + +extension StructuredText { + /// Creates a structured-text view from a Markdown string. + /// + /// This is a convenience initializer that uses Textual’s Markdown parser. To render other + /// markup formats, use ``init(_:parser:)`` with a custom ``MarkupParser``. + /// + /// - Parameters: + /// - markdown: The Markdown source to render. + /// - baseURL: A base URL used to resolve relative links and image URLs. + /// - syntaxExtensions: Custom syntax extensions applied after markdown parsing. + /// + /// Math expressions are supported when you include `.math` in `syntaxExtensions`: + /// + /// ```swift + /// StructuredText( + /// markdown: "The area is $A = \\pi r^2$.", + /// syntaxExtensions: [.math] + /// ) + /// ``` + public init( + markdown: String, + baseURL: URL? = nil, + syntaxExtensions: [AttributedStringMarkdownParser.SyntaxExtension] = [] + ) { + self.init( + markdown, + parser: .markdown( + baseURL: baseURL, + syntaxExtensions: syntaxExtensions + ) + ) + } +} + +@available(tvOS, unavailable) +@available(watchOS, unavailable) +#Preview(traits: .fixedLayout(width: 400, height: 600)) { + @Previewable @State var width: CGFloat = 200 + + VStack { + GroupBox { + HStack { + Text("Width") + Slider(value: $width, in: 100...320) + } + } + Spacer() + + StructuredText( + markdown: """ + Morty, do you know what _“wubba lubba dub dub”_ means? + + ![Hamster in Butt World](https://rickandmortyapi.com/api/character/avatar/153.jpeg) + + I mean, why would a [Pop-Tart](https://en.wikipedia.org/wiki/Pop-Tarts) \ + want to live inside a toaster, Rick? I mean, that would be like the \ + scariest place for them to live. You know what I mean? + """ + ) + .frame(width: width) + .border(Color.red) + .environment( + \.openURL, + OpenURLAction { url in + print("Opening \(url)") + return .handled + } + ) + .padding() + .textual.textSelection(.enabled) + + Spacer() + } +} + +#Preview("Custom Emoji") { + let emoji: Set = [ + Emoji(shortcode: "dog", url: URL(string: "https://picsum.photos/id/237/32/32")!), + Emoji(shortcode: "cat", url: URL(string: "https://picsum.photos/id/1025/32/32")!), + ] + + ScrollView { + StructuredText( + markdown: """ + # Working with Custom Emoji + + You can substitute shortcodes with inline images. For example, :dog: and :cat: render \ + as small inline attachments that flow with the surrounding text. + """, + syntaxExtensions: [.emoji(emoji)] + ) + .padding() + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/BlockQuoteStyle.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/BlockQuoteStyle.swift new file mode 100644 index 000000000..ea118f016 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/BlockQuoteStyle.swift @@ -0,0 +1,21 @@ +import SwiftUI + +extension StructuredText { + /// A style that controls how `StructuredText` renders block quotes. + /// + /// Apply a block quote style with ``TextualNamespace/blockQuoteStyle(_:)`` or through a bundled + /// ``StructuredText/Style``. + public protocol BlockQuoteStyle: DynamicProperty { + associatedtype Body: View + + /// Creates a view that represents a block quote. + @MainActor @ViewBuilder func makeBody(configuration: Self.Configuration) -> Self.Body + + typealias Configuration = BlockStyleConfiguration + } +} + +extension EnvironmentValues { + @usableFromInline + @Entry var blockQuoteStyle: any StructuredText.BlockQuoteStyle = .default +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/BlockSpacing.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/BlockSpacing.swift new file mode 100644 index 000000000..efaec2fa5 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/BlockSpacing.swift @@ -0,0 +1,45 @@ +import SwiftUI + +extension StructuredText { + /// Additional spacing to apply above and below a block element. + /// + /// A `nil` value means “don’t override” so that spacing can be resolved by the surrounding + /// styles. + public struct BlockSpacing: Sendable, Hashable { + /// The spacing to apply above the block, or `nil` to leave it unchanged. + public var top: CGFloat? + /// The spacing to apply below the block, or `nil` to leave it unchanged. + public var bottom: CGFloat? + + /// Creates a block spacing value. + public init(top: CGFloat? = nil, bottom: CGFloat? = nil) { + self.top = top + self.bottom = bottom + } + + /// Returns a spacing that prefers the larger of each edge, ignoring `nil` values. + @inlinable + public func union(_ other: BlockSpacing) -> BlockSpacing { + .init( + top: [top, other.top].compactMap(\.self).max(), + bottom: [bottom, other.bottom].compactMap(\.self).max() + ) + } + } +} + +extension StructuredText.BlockSpacing: FontScalable { + public func scaled(by fontSize: CGFloat) -> StructuredText.BlockSpacing { + .init( + top: top.map { $0 * fontSize }, + bottom: bottom.map { $0 * fontSize } + ) + } +} + +extension FontScaled where Value == StructuredText.BlockSpacing { + /// A convenience constructor for font-scaled `StructuredText.BlockSpacing` values. + public static func fontScaled(top: CGFloat? = nil, bottom: CGFloat? = nil) -> Self { + FontScaled(StructuredText.BlockSpacing(top: top, bottom: bottom)) + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/BlockStyleConfiguration.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/BlockStyleConfiguration.swift new file mode 100644 index 000000000..5deb1db5d --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/BlockStyleConfiguration.swift @@ -0,0 +1,19 @@ +import SwiftUI + +extension StructuredText { + /// The properties of a block element passed to styles like paragraphs and block quotes. + public struct BlockStyleConfiguration { + /// A type-erased view that contains the block content. + public struct Label: View { + init(_ label: some View) { + self.body = AnyView(label) + } + public let body: AnyView + } + + /// The block content. + public let label: Label + /// The indentation level of the block within the document structure. + public let indentationLevel: Int + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/CodeBlockProxy.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/CodeBlockProxy.swift new file mode 100644 index 000000000..f2f2000ac --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/CodeBlockProxy.swift @@ -0,0 +1,39 @@ +import SwiftUI +import UniformTypeIdentifiers + +extension StructuredText { + /// A proxy for a rendered code block that custom code block styles can use. + public struct CodeBlockProxy { + private let content: AttributedSubstring + + internal init(_ content: AttributedSubstring) { + self.content = content + } + + /// Copies the code block contents to the system pasteboard. + /// + /// Textual writes both a plain-text and an HTML representation when possible. + @available(tvOS, unavailable) + @available(watchOS, unavailable) + public func copyToPasteboard() { + #if TEXTUAL_ENABLE_TEXT_SELECTION && canImport(AppKit) && !targetEnvironment(macCatalyst) + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + + let formatter = Formatter(AttributedString(content)) + pasteboard.setString(formatter.plainText(), forType: .string) + pasteboard.setString(formatter.html(), forType: .html) + #elseif TEXTUAL_ENABLE_TEXT_SELECTION && canImport(UIKit) + let formatter = Formatter(AttributedString(content)) + UIPasteboard.general.setItems( + [ + [ + UTType.plainText.identifier: formatter.plainText(), + UTType.html.identifier: formatter.html(), + ] + ] + ) + #endif + } + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/CodeBlockStyle.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/CodeBlockStyle.swift new file mode 100644 index 000000000..ca1406f18 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/CodeBlockStyle.swift @@ -0,0 +1,43 @@ +import SwiftUI + +extension StructuredText { + /// The properties of a code block passed to a `CodeBlockStyle`. + public struct CodeBlockStyleConfiguration { + /// A type-erased view that contains the code block content. + public struct Label: View { + init(_ label: some View) { + self.body = AnyView(label) + } + public let body: AnyView + } + + /// The code block content. + public let label: Label + /// The indentation level of the code block within the document structure. + public let indentationLevel: Int + /// A language hint extracted from the fenced code block, if present. + public let languageHint: String? + /// A proxy that provides code-block actions. + public let codeBlock: CodeBlockProxy + /// The syntax-highlighting theme used for this code block. + public let highlighterTheme: HighlighterTheme + } + + /// A style that controls how `StructuredText` renders code blocks. + /// + /// Apply a code block style with ``TextualNamespace/codeBlockStyle(_:)`` or through a bundled + /// ``StructuredText/Style``. + public protocol CodeBlockStyle: DynamicProperty { + associatedtype Body: View + + /// Creates a view that represents a code block. + @MainActor @ViewBuilder func makeBody(configuration: Self.Configuration) -> Self.Body + + typealias Configuration = CodeBlockStyleConfiguration + } +} + +extension EnvironmentValues { + @usableFromInline + @Entry var codeBlockStyle: any StructuredText.CodeBlockStyle = .default +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Default/DefaultBlockQuoteStyle.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Default/DefaultBlockQuoteStyle.swift new file mode 100644 index 000000000..6290ce318 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Default/DefaultBlockQuoteStyle.swift @@ -0,0 +1,55 @@ +import SwiftUI + +extension StructuredText { + /// The default block quote style used by ``StructuredText/DefaultStyle``. + public struct DefaultBlockQuoteStyle: BlockQuoteStyle { + private let backgroundColor: DynamicColor + private let borderColor: DynamicColor + + /// Creates a block quote style with a background and a leading border. + public init(backgroundColor: DynamicColor, borderColor: DynamicColor) { + self.backgroundColor = backgroundColor + self.borderColor = borderColor + } + + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .frame(maxWidth: .infinity, alignment: .leading) + .textual.lineSpacing(.fontScaled(0.471)) + .textual.padding(.fontScaled(0.941)) + .background { + ZStack(alignment: .leading) { + Rectangle() + .fill(backgroundColor) + Rectangle() + .fill(borderColor) + .frame(width: 6, alignment: .leading) + } + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + } + } +} + +extension StructuredText.BlockQuoteStyle where Self == StructuredText.DefaultBlockQuoteStyle { + /// The default block quote style. + public static var `default`: Self { + .init(backgroundColor: .asideBackground, borderColor: .asideBorder) + } +} + +@available(tvOS, unavailable) +@available(watchOS, unavailable) +#Preview { + StructuredText( + markdown: """ + The sky above the port was the color of television, tuned to a dead channel. + + > Outside of a dog, a book is man's best friend. Inside of a dog it's too dark to read. + + It was a bright cold day in April, and the clocks were striking thirteen. + """ + ) + .padding() + .textual.textSelection(.enabled) +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Default/DefaultCodeBlockStyle.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Default/DefaultCodeBlockStyle.swift new file mode 100644 index 000000000..65ac7142d --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Default/DefaultCodeBlockStyle.swift @@ -0,0 +1,58 @@ +import SwiftUI + +extension StructuredText { + /// The default code block style used by ``StructuredText/DefaultStyle``. + public struct DefaultCodeBlockStyle: CodeBlockStyle { + /// Creates the default code block style. + public init() {} + + public func makeBody(configuration: Configuration) -> some View { + Overflow { + configuration.label + .textual.lineSpacing(.fontScaled(0.39)) + .textual.fontScale(0.882) + .fixedSize(horizontal: false, vertical: true) + .monospaced() + .padding(.vertical, 8) + .padding(.leading, 14) + } + .background(configuration.highlighterTheme.backgroundColor) + .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .stroke(DynamicColor.grid, lineWidth: 1) + ) + .textual.blockSpacing(.fontScaled(top: 0.88, bottom: 0)) + } + } +} + +extension StructuredText.CodeBlockStyle where Self == StructuredText.DefaultCodeBlockStyle { + /// The default code block style. + public static var `default`: Self { + .init() + } +} + +@available(tvOS, unavailable) +@available(watchOS, unavailable) +#Preview { + StructuredText( + markdown: """ + The sky above the port was the color of television, tuned to a dead channel. + + ```swift + struct Sightseeing: Activity { + func perform(with sloth: inout Sloth) -> Speed { + sloth.energyLevel -= 10 + return .slow + } + } + ``` + + It was a bright cold day in April, and the clocks were striking thirteen. + """ + ) + .padding() + .textual.textSelection(.enabled) +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Default/DefaultHeadingStyle.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Default/DefaultHeadingStyle.swift new file mode 100644 index 000000000..819f909d1 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Default/DefaultHeadingStyle.swift @@ -0,0 +1,52 @@ +import SwiftUI + +extension StructuredText { + /// The default heading style used by ``StructuredText/DefaultStyle``. + public struct DefaultHeadingStyle: HeadingStyle { + private static let lineSpacings: [CGFloat] = [0.1, 0.25, 0.143, 0.167, 0.182, 0.471] + private static let fontScales: [CGFloat] = [2.353, 1.882, 1.647, 1.412, 1.294, 1] + + public func makeBody(configuration: Configuration) -> some View { + let headingLevel = min(configuration.headingLevel, 6) + let lineSpacing = Self.lineSpacings[headingLevel - 1] + let fontScale = Self.fontScales[headingLevel - 1] + + configuration.label + .textual.fontScale(fontScale) + .textual.lineSpacing(.fontScaled(lineSpacing)) + .textual.blockSpacing(.fontScaled(top: 1.6, bottom: 0.8)) + .fontWeight(.semibold) + } + } +} + +extension StructuredText.HeadingStyle where Self == StructuredText.DefaultHeadingStyle { + /// The default heading style. + public static var `default`: Self { + .init() + } +} + +@available(tvOS, unavailable) +@available(watchOS, unavailable) +#Preview { + StructuredText( + markdown: """ + Paragraph. + # Heading 1 + Paragraph. + ## Heading 2 + Paragraph. + ### Heading 3 + Paragraph. + #### Heading 4 + Paragraph. + ##### Heading 5 + Paragraph. + ###### Heading 6 + Paragraph. + """ + ) + .padding() + .textual.textSelection(.enabled) +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Default/DefaultListItemStyle.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Default/DefaultListItemStyle.swift new file mode 100644 index 000000000..8c960d877 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Default/DefaultListItemStyle.swift @@ -0,0 +1,85 @@ +import SwiftUI + +extension StructuredText { + /// The default list item style used by ``StructuredText/DefaultStyle``. + public struct DefaultListItemStyle: ListItemStyle { + private let markerSpacing: FontScaled + + /// Creates a list item style with a custom marker spacing. + /// + /// - Parameter markerSpacing: The font-relative horizontal spacing between the marker and the item content. + public init(markerSpacing: FontScaled) { + self.markerSpacing = markerSpacing + } + + public func makeBody(configuration: Configuration) -> some View { + WithFontScaledValue(markerSpacing) { + HStack(alignment: .firstTextCenter, spacing: $0) { + configuration.marker + configuration.block + } + } + } + } +} + +extension StructuredText.ListItemStyle where Self == StructuredText.DefaultListItemStyle { + /// The default list item style. + public static var `default`: Self { + .init(markerSpacing: .fontScaled(0.5)) + } + + /// The default list item style with a custom marker spacing. + /// + /// - Parameter markerSpacing: The font-relative horizontal spacing between the marker and the item content. + public static func `default`(markerSpacing: FontScaled) -> Self { + .init(markerSpacing: markerSpacing) + } +} + +@available(tvOS, unavailable) +@available(watchOS, unavailable) +#Preview("OrderedList") { + StructuredText( + markdown: """ + This is an incomplete list of headgear: + + 1. Hats + 1. Caps + 1. Bonnets + + Some more: + + 10. Helmets + 1. Hoods + 1. Headbands + 1. Headscarves + 1. Wimples + + A list with a high start: + + 999. The sky above the port was the color of television, tuned to a dead channel. + 1. It was a bright cold day in April, and the clocks were striking thirteen. + """ + ) + .padding() + .textual.textSelection(.enabled) +} + +@available(tvOS, unavailable) +@available(watchOS, unavailable) +#Preview("UnorderedList") { + StructuredText( + markdown: """ + * Systems + * FFF units + * Great Underground Empire (Zork) + * Potrzebie + * Equals the thickness of Mad issue 26 + * Developed by 19-year-old Donald E. Knuth + """ + ) + .padding() + .textual.textSelection(.enabled) + .textual.unorderedListMarker(.hierarchical(.disc, .circle, .square)) +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Default/DefaultParagraphStyle.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Default/DefaultParagraphStyle.swift new file mode 100644 index 000000000..faa06103f --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Default/DefaultParagraphStyle.swift @@ -0,0 +1,38 @@ +import SwiftUI + +extension StructuredText { + /// The default paragraph style used by ``StructuredText/DefaultStyle``. + public struct DefaultParagraphStyle: ParagraphStyle { + /// Creates the default paragraph style. + public init() {} + + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .textual.lineSpacing(.fontScaled(0.23)) + .textual.blockSpacing(.fontScaled(top: 0.8)) + } + } +} + +extension StructuredText.ParagraphStyle where Self == StructuredText.DefaultParagraphStyle { + /// The default paragraph style. + public static var `default`: Self { + .init() + } +} + +@available(tvOS, unavailable) +@available(watchOS, unavailable) +#Preview { + StructuredText( + markdown: """ + The sky above the port was the color of television, + tuned to a dead channel. + + It was a bright cold day in April, and the clocks were + striking thirteen. + """ + ) + .padding() + .textual.textSelection(.enabled) +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Default/DefaultStyle.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Default/DefaultStyle.swift new file mode 100644 index 000000000..24f0f8f0b --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Default/DefaultStyle.swift @@ -0,0 +1,29 @@ +import SwiftUI + +extension StructuredText { + /// The default set of styles for structured text. + /// + /// Apply this style with ``TextualNamespace/structuredTextStyle(_:)``. + public struct DefaultStyle: Style { + public let inlineStyle: InlineStyle = .default + public let headingStyle: DefaultHeadingStyle = .default + public let paragraphStyle: DefaultParagraphStyle = .default + public let blockQuoteStyle: DefaultBlockQuoteStyle = .default + public let codeBlockStyle: DefaultCodeBlockStyle = .default + public let listItemStyle: DefaultListItemStyle = .default + public let unorderedListMarker: SymbolListMarker = .disc + public let orderedListMarker: DecimalListMarker = .decimal + public let tableStyle: DefaultTableStyle = .default + public let tableCellStyle: DefaultTableCellStyle = .default + public let thematicBreakStyle: DividerThematicBreakStyle = .divider + + public init() {} + } +} + +extension StructuredText.Style where Self == StructuredText.DefaultStyle { + /// The default structured text style. + public static var `default`: Self { + .init() + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Default/DefaultTableCellStyle.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Default/DefaultTableCellStyle.swift new file mode 100644 index 000000000..152943403 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Default/DefaultTableCellStyle.swift @@ -0,0 +1,23 @@ +import SwiftUI + +extension StructuredText { + /// The default table cell style used by ``StructuredText/DefaultStyle``. + public struct DefaultTableCellStyle: TableCellStyle { + /// Creates the default table cell style. + public init() {} + + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .fontWeight(configuration.row == 0 ? .semibold : .regular) + .textual.lineSpacing(.fontScaled(0.471)) + .textual.padding(.fontScaled(0.588)) + } + } +} + +extension StructuredText.TableCellStyle where Self == StructuredText.DefaultTableCellStyle { + /// The default table cell style. + public static var `default`: Self { + .init() + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Default/DefaultTableStyle.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Default/DefaultTableStyle.swift new file mode 100644 index 000000000..6b3d7c676 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Default/DefaultTableStyle.swift @@ -0,0 +1,56 @@ +import SwiftUI + +extension StructuredText { + /// The default table style used by ``StructuredText/DefaultStyle``. + public struct DefaultTableStyle: TableStyle { + private static let borderWidth: CGFloat = 1 + + /// Creates the default table style. + public init() {} + + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .textual.tableCellSpacing(horizontal: Self.borderWidth, vertical: Self.borderWidth) + .textual.blockSpacing(.fontScaled(top: 1.6, bottom: 1.6)) + .textual.tableOverlay { layout in + Canvas { context, _ in + for divider in layout.dividers() { + context.fill( + Path(divider), + with: .style(DynamicColor.grayTertiary) + ) + } + } + } + .padding(Self.borderWidth) + } + } +} + +extension StructuredText.TableStyle where Self == StructuredText.DefaultTableStyle { + /// The default table style. + public static var `default`: Self { + .init() + } +} + +@available(tvOS, unavailable) +@available(watchOS, unavailable) +#Preview { + StructuredText( + markdown: """ + The sky above the port was the color of television, tuned to a dead channel. + + Sloth speed | Description + ------------ | ------------------------------------- + `slow` | Moves slightly faster than a snail + `medium` | Moves at an average speed + `fast` | Moves faster than a hare + `supersonic` | Moves faster than the speed of sound + + It was a bright cold day in April, and the clocks were striking thirteen. + """ + ) + .padding() + .textual.textSelection(.enabled) +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Default/DividerThematicBreakStyle.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Default/DividerThematicBreakStyle.swift new file mode 100644 index 000000000..d4e027585 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Default/DividerThematicBreakStyle.swift @@ -0,0 +1,39 @@ +import SwiftUI + +extension StructuredText { + /// A thematic break style that renders a `Divider`. + public struct DividerThematicBreakStyle: ThematicBreakStyle { + /// Creates a divider thematic break style. + public init() {} + + public func makeBody(configuration _: Configuration) -> some View { + Divider() + .frame(minHeight: 1) + .overlay(DynamicColor.grid) + .textual.blockSpacing(.fontScaled(top: 1.6, bottom: 1.6)) + } + } +} + +extension StructuredText.ThematicBreakStyle where Self == StructuredText.DividerThematicBreakStyle { + /// A thematic break style that uses a `Divider`. + public static var divider: Self { + .init() + } +} + +@available(tvOS, unavailable) +@available(watchOS, unavailable) +#Preview { + StructuredText( + markdown: """ + The sky above the port was the color of television, tuned to a dead channel. + + --- + + It was a bright cold day in April, and the clocks were striking thirteen. + """ + ) + .padding() + .textual.textSelection(.enabled) +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Default/DynamicColor+Default.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Default/DynamicColor+Default.swift new file mode 100644 index 000000000..5d6667ca8 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Default/DynamicColor+Default.swift @@ -0,0 +1,28 @@ +import SwiftUI + +extension DynamicColor { + static let grid = DynamicColor( + light: Color(red: 210 / 255, green: 210 / 255, blue: 215 / 255), + dark: Color(red: 66 / 255, green: 66 / 255, blue: 69 / 255) + ) + + static let asideBackground = DynamicColor( + light: Color(red: 245 / 255, green: 245 / 255, blue: 245 / 255), + dark: Color(red: 34 / 255, green: 34 / 255, blue: 34 / 255) + ) + + static let asideBorder: DynamicColor = .init( + light: Color(red: 102 / 255, green: 102 / 255, blue: 102 / 255), + dark: Color(red: 176 / 255, green: 176 / 255, blue: 176 / 255) + ) + + static let link: DynamicColor = .init( + light: Color(red: 51 / 255, green: 102 / 255, blue: 255 / 255), + dark: Color(red: 0 / 255, green: 153 / 255, blue: 255 / 255) + ) + + static let grayTertiary: DynamicColor = .init( + light: Color(red: 240 / 255, green: 240 / 255, blue: 240 / 255), + dark: Color(red: 66 / 255, green: 66 / 255, blue: 66 / 255) + ) +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Default/OverflowTableStyle.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Default/OverflowTableStyle.swift new file mode 100644 index 000000000..2aadf95b2 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Default/OverflowTableStyle.swift @@ -0,0 +1,78 @@ +import SwiftUI + +extension StructuredText { + /// A table style that enables horizontal scrolling with a relative max width ratio. + /// + /// Use ``TextualNamespace/overflowMode(_:)`` to switch between scrolling and wrapping. + public struct OverflowTableStyle: TableStyle { + private static let borderWidth: CGFloat = 1 + + private let relativeWidth: CGFloat + + /// Creates an overflow table style. + /// + /// - Parameter relativeWidth: The maximum width ratio relative to the scroll container width. Defaults to `1.5`. + public init(relativeWidth: CGFloat = 1.5) { + self.relativeWidth = relativeWidth + } + + public func makeBody(configuration: Configuration) -> some View { + Overflow { state in + let maxWidth = state.containerWidth.map { + $0 * relativeWidth + } + configuration.label + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: maxWidth, alignment: .leading) + .textual.tableOverlay { layout in + Canvas { context, _ in + for divider in layout.dividers() { + context.fill( + Path(divider), + with: .style(DynamicColor.grayTertiary) + ) + } + } + } + .padding(Self.borderWidth) + } + .textual.tableCellSpacing(horizontal: Self.borderWidth, vertical: Self.borderWidth) + .textual.blockSpacing(.fontScaled(top: 1.6, bottom: 1.6)) + } + } +} + +extension StructuredText.TableStyle where Self == StructuredText.OverflowTableStyle { + /// A table style that enables horizontal scrolling with a relative max width. + public static var overflow: Self { + .init() + } + + /// A table style that enables horizontal scrolling with a relative max width. + /// + /// - Parameter relativeWidth: The maximum width ratio relative to the scroll container width. + public static func overflow(relativeWidth: CGFloat) -> Self { + .init(relativeWidth: relativeWidth) + } +} + +@available(tvOS, unavailable) +@available(watchOS, unavailable) +#Preview { + StructuredText( + markdown: """ + The sky above the port was the color of television, tuned to a dead channel. + + Sloth speed | Description | Notes + ------------ | ------------------------------------- | --------------------------------------------- + `slow` | Moves slightly faster than a snail | Good for sightseeing along the waterfront. + `medium` | Moves at an average speed | Balanced choice for day-to-day commuting. + `fast` | Moves faster than a hare | Best for urgent deliveries across town. + `supersonic` | Moves faster than the speed of sound | Only for the bravest sloths. + + It was a bright cold day in April, and the clocks were striking thirteen. + """ + ) + .padding() + .textual.tableStyle(.overflow) +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/GitHub/DynamicColor+GitHub.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/GitHub/DynamicColor+GitHub.swift new file mode 100644 index 000000000..3692955b0 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/GitHub/DynamicColor+GitHub.swift @@ -0,0 +1,36 @@ +import SwiftUI + +extension DynamicColor { + static let gitHubPrimary = DynamicColor( + light: Color(red: 6 / 255, green: 6 / 255, blue: 6 / 255), + dark: Color(red: 251 / 255, green: 251 / 255, blue: 252 / 255) + ) + static let gitHubSecondary = DynamicColor( + light: Color(red: 107 / 255, green: 110 / 255, blue: 123 / 255), + dark: Color(red: 146 / 255, green: 148 / 255, blue: 160 / 255) + ) + static let gitHubTertiary = DynamicColor( + light: Color(red: 107 / 255, green: 110 / 255, blue: 123 / 255), + dark: Color(red: 109 / 255, green: 112 / 255, blue: 125 / 255) + ) + static let gitHubBackground = DynamicColor( + light: .white, + dark: Color(red: 24 / 255, green: 25 / 255, blue: 29 / 255) + ) + static let gitHubSecondaryBackground = DynamicColor( + light: Color(red: 247 / 255, green: 247 / 255, blue: 249 / 255), + dark: Color(red: 37 / 255, green: 38 / 255, blue: 42 / 255) + ) + static let gitHubLink = DynamicColor( + light: Color(red: 44 / 255, green: 101 / 255, blue: 207 / 255), + dark: Color(red: 76 / 255, green: 142 / 255, blue: 248 / 255) + ) + static let gitHubBorder = DynamicColor( + light: Color(red: 228 / 255, green: 228 / 255, blue: 232 / 255), + dark: Color(red: 66 / 255, green: 68 / 255, blue: 78 / 255) + ) + static let gitHubDivider = DynamicColor( + light: Color(red: 208 / 255, green: 208 / 255, blue: 211 / 255), + dark: Color(red: 51 / 255, green: 52 / 255, blue: 56 / 255) + ) +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/GitHub/GitHubBlockQuoteStyle.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/GitHub/GitHubBlockQuoteStyle.swift new file mode 100644 index 000000000..d701d1795 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/GitHub/GitHubBlockQuoteStyle.swift @@ -0,0 +1,43 @@ +import SwiftUI + +extension StructuredText { + /// A block quote style inspired by GitHub’s rendering. + public struct GitHubBlockQuoteStyle: BlockQuoteStyle { + /// Creates the GitHub block quote style. + public init() { + } + + public func makeBody(configuration: Configuration) -> some View { + HStack(spacing: 0) { + RoundedRectangle(cornerRadius: 6) + .fill(DynamicColor.gitHubBorder) + .textual.frame(width: .fontScaled(0.2)) + configuration.label + .foregroundStyle(DynamicColor.gitHubSecondary) + .textual.padding(.horizontal, .fontScaled(1)) + } + } + } +} + +extension StructuredText.BlockQuoteStyle where Self == StructuredText.GitHubBlockQuoteStyle { + /// A GitHub-like block quote style. + public static var gitHub: Self { + .init() + } +} + +#Preview { + StructuredText( + markdown: """ + The sky above the port was the color of television, tuned to a dead channel. + + > Outside of a dog, a book is man's best friend. Inside of a dog it's too dark to read. + + It was a bright cold day in April, and the clocks were striking thirteen. + """ + ) + .padding() + .textual.blockQuoteStyle(.gitHub) + .textual.paragraphStyle(.gitHub) +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/GitHub/GitHubCodeBlockStyle.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/GitHub/GitHubCodeBlockStyle.swift new file mode 100644 index 000000000..492f81c17 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/GitHub/GitHubCodeBlockStyle.swift @@ -0,0 +1,52 @@ +import SwiftUI + +extension StructuredText { + /// A code block style inspired by GitHub’s rendering. + public struct GitHubCodeBlockStyle: CodeBlockStyle { + /// Creates the GitHub code block style. + public init() {} + + public func makeBody(configuration: Configuration) -> some View { + Overflow { + configuration.label + .textual.lineSpacing(.fontScaled(0.225)) + .textual.fontScale(0.85) + .fixedSize(horizontal: false, vertical: true) + .monospaced() + .padding(16) + } + .background(DynamicColor.gitHubSecondaryBackground) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .textual.blockSpacing(.init(top: 0, bottom: 16)) + } + } +} + +extension StructuredText.CodeBlockStyle where Self == StructuredText.GitHubCodeBlockStyle { + /// A GitHub-like code block style. + public static var gitHub: Self { + .init() + } +} + +#Preview { + StructuredText( + markdown: """ + The sky above the port was the color of television, tuned to a dead channel. + + ```swift + struct Sightseeing: Activity { + func perform(with sloth: inout Sloth) -> Speed { + sloth.energyLevel -= 10 + return .slow + } + } + ``` + + It was a bright cold day in April, and the clocks were striking thirteen. + """ + ) + .padding() + .textual.codeBlockStyle(.gitHub) + .textual.paragraphStyle(.gitHub) +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/GitHub/GitHubHeadingStyle.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/GitHub/GitHubHeadingStyle.swift new file mode 100644 index 000000000..0071c5cea --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/GitHub/GitHubHeadingStyle.swift @@ -0,0 +1,69 @@ +import SwiftUI + +extension StructuredText { + /// A heading style inspired by GitHub’s rendering. + public struct GitHubHeadingStyle: HeadingStyle { + private static let fontScales: [CGFloat] = [2, 1.5, 1.25, 1, 0.875, 0.85] + + public func makeBody(configuration: Configuration) -> some View { + let headingLevel = min(configuration.headingLevel, 6) + let fontScale = Self.fontScales[headingLevel - 1] + + WithFontScaledValue(.fontScaled(0.3)) { spacing in + VStack(alignment: .leading, spacing: spacing) { + makeLabel(configuration: configuration) + .textual.fontScale(fontScale) + .textual.lineSpacing(.fontScaled(0.125)) + .textual.blockSpacing(.init(top: 24, bottom: 16)) + .fontWeight(.semibold) + if headingLevel <= 2 { + Divider() + .overlay(DynamicColor.gitHubDivider) + } + } + } + } + + @ViewBuilder + private func makeLabel(configuration: Configuration) -> some View { + if min(configuration.headingLevel, 6) == 6 { + configuration.label + .foregroundStyle(DynamicColor.gitHubTertiary) + } else { + configuration.label + } + } + } +} + +extension StructuredText.HeadingStyle where Self == StructuredText.GitHubHeadingStyle { + /// A GitHub-like heading style. + public static var gitHub: Self { + .init() + } +} + +#Preview { + ScrollView { + StructuredText( + markdown: """ + Paragraph. + # Heading 1 + Paragraph. + ## Heading 2 + Paragraph. + ### Heading 3 + Paragraph. + #### Heading 4 + Paragraph. + ##### Heading 5 + Paragraph. + ###### Heading 6 + Paragraph. + """ + ) + .padding() + } + .textual.headingStyle(.gitHub) + .textual.paragraphStyle(.gitHub) +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/GitHub/GitHubParagraphStyle.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/GitHub/GitHubParagraphStyle.swift new file mode 100644 index 000000000..da3f547c9 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/GitHub/GitHubParagraphStyle.swift @@ -0,0 +1,36 @@ +import SwiftUI + +extension StructuredText { + /// A paragraph style inspired by GitHub’s rendering. + public struct GitHubParagraphStyle: ParagraphStyle { + /// Creates the GitHub paragraph style. + public init() {} + + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .textual.lineSpacing(.fontScaled(0.25)) + .textual.blockSpacing(.init(top: 0, bottom: 16)) + } + } +} + +extension StructuredText.ParagraphStyle where Self == StructuredText.GitHubParagraphStyle { + /// A GitHub-like paragraph style. + public static var gitHub: Self { + .init() + } +} + +#Preview { + StructuredText( + markdown: """ + The sky above the port was the color of television, + tuned to a dead channel. + + It was a bright cold day in April, and the clocks were + striking thirteen. + """ + ) + .padding() + .textual.paragraphStyle(.gitHub) +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/GitHub/GitHubStyle.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/GitHub/GitHubStyle.swift new file mode 100644 index 000000000..50a6610e1 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/GitHub/GitHubStyle.swift @@ -0,0 +1,30 @@ +import SwiftUI + +extension StructuredText { + /// A GitHub-like set of styles for structured text. + /// + /// Apply this style with ``TextualNamespace/structuredTextStyle(_:)``. + public struct GitHubStyle: Style { + public let inlineStyle: InlineStyle = .gitHub + public let headingStyle: GitHubHeadingStyle = .gitHub + public let paragraphStyle: GitHubParagraphStyle = .gitHub + public let blockQuoteStyle: GitHubBlockQuoteStyle = .gitHub + public let codeBlockStyle: GitHubCodeBlockStyle = .gitHub + public let listItemStyle: DefaultListItemStyle = .default + public let unorderedListMarker: HierarchicalSymbolListMarker = .hierarchical( + .disc, .circle, .square) + public let orderedListMarker: DecimalListMarker = .decimal + public let tableStyle: GitHubTableStyle = .gitHub + public let tableCellStyle: GitHubTableCellStyle = .gitHub + public let thematicBreakStyle: GitHubThematicBreakStyle = .gitHub + + public init() {} + } +} + +extension StructuredText.Style where Self == StructuredText.GitHubStyle { + /// A GitHub-like structured text style. + public static var gitHub: Self { + .init() + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/GitHub/GitHubTableCellStyle.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/GitHub/GitHubTableCellStyle.swift new file mode 100644 index 000000000..4ecf10848 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/GitHub/GitHubTableCellStyle.swift @@ -0,0 +1,24 @@ +import SwiftUI + +extension StructuredText { + /// A table cell style inspired by GitHub’s rendering. + public struct GitHubTableCellStyle: TableCellStyle { + /// Creates the GitHub table cell style. + public init() {} + + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .fontWeight(configuration.row == 0 ? .semibold : .regular) + .padding(.vertical, 6) + .padding(.horizontal, 13) + .textual.lineSpacing(.fontScaled(0.25)) + } + } +} + +extension StructuredText.TableCellStyle where Self == StructuredText.GitHubTableCellStyle { + /// A GitHub-like table cell style. + public static var gitHub: Self { + .init() + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/GitHub/GitHubTableStyle.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/GitHub/GitHubTableStyle.swift new file mode 100644 index 000000000..50ab5f22a --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/GitHub/GitHubTableStyle.swift @@ -0,0 +1,77 @@ +import SwiftUI + +extension StructuredText { + /// A table style inspired by GitHub’s rendering. + public struct GitHubTableStyle: TableStyle { + private static let borderWidth: CGFloat = 1 + + /// Creates the GitHub table style. + public init() {} + + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .textual.tableCellSpacing(horizontal: Self.borderWidth, vertical: Self.borderWidth) + .textual.blockSpacing(.init(top: 0, bottom: 16)) + .textual.tableBackground { layout in + Canvas { context, _ in + for bounds in layout.evenRowBounds { + context.fill( + Path(bounds.integral), + with: .style(DynamicColor.gitHubSecondaryBackground) + ) + } + } + } + .textual.tableOverlay { layout in + Canvas { context, _ in + for divider in layout.dividers() { + context.fill( + Path(divider), + with: .style(DynamicColor.gitHubBorder) + ) + } + } + } + .padding(Self.borderWidth) + .border(DynamicColor.gitHubBorder, width: Self.borderWidth) + } + } +} + +extension StructuredText.TableLayout { + fileprivate var evenRowBounds: [CGRect] { + rowIndices + .dropFirst() + .filter { $0.isMultiple(of: 2) } + .map { rowBounds($0) } + } +} + +extension StructuredText.TableStyle where Self == StructuredText.GitHubTableStyle { + /// A GitHub-like table style. + public static var gitHub: Self { + .init() + } +} + +#Preview { + StructuredText( + markdown: """ + The sky above the port was the color of television, tuned to a dead channel. + + Sloth speed | Description + ------------ | ------------------------------------- + `slow` | Moves slightly faster than a snail + `medium` | Moves at an average speed + `fast` | Moves faster than a hare + `supersonic` | Moves faster than the speed of sound + + It was a bright cold day in April, and the clocks were striking thirteen. + """ + ) + .padding() + .textual.inlineStyle(.gitHub) + .textual.paragraphStyle(.gitHub) + .textual.tableCellStyle(.gitHub) + .textual.tableStyle(.gitHub) +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/GitHub/GitHubThematicBreak.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/GitHub/GitHubThematicBreak.swift new file mode 100644 index 000000000..c7676381e --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/GitHub/GitHubThematicBreak.swift @@ -0,0 +1,38 @@ +import SwiftUI + +extension StructuredText { + /// A thematic break style inspired by GitHub’s rendering. + public struct GitHubThematicBreakStyle: ThematicBreakStyle { + /// Creates the GitHub thematic break style. + public init() {} + + public func makeBody(configuration _: Configuration) -> some View { + Divider() + .textual.frame(height: .fontScaled(0.25)) + .overlay(DynamicColor.gitHubBorder) + .textual.blockSpacing(.init(top: 24, bottom: 24)) + } + } +} + +extension StructuredText.ThematicBreakStyle where Self == StructuredText.GitHubThematicBreakStyle { + /// A GitHub-like thematic break style. + public static var gitHub: Self { + .init() + } +} + +#Preview { + StructuredText( + markdown: """ + The sky above the port was the color of television, tuned to a dead channel. + + --- + + It was a bright cold day in April, and the clocks were striking thirteen. + """ + ) + .padding() + .textual.paragraphStyle(.gitHub) + .textual.thematicBreakStyle(.gitHub) +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/HeadingStyle.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/HeadingStyle.swift new file mode 100644 index 000000000..60fbb4330 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/HeadingStyle.swift @@ -0,0 +1,39 @@ +import SwiftUI + +extension StructuredText { + /// The properties of a heading passed to a `HeadingStyle`. + public struct HeadingStyleConfiguration { + /// A type-erased view that contains the heading content. + public struct Label: View { + init(_ label: some View) { + self.body = AnyView(label) + } + public let body: AnyView + } + + /// The heading content. + public let label: Label + /// The indentation level of the heading within the document structure. + public let indentationLevel: Int + /// The heading level, from `1` (most prominent) to `6` (least prominent). + public let headingLevel: Int + } + + /// A style that controls how `StructuredText` renders headings. + /// + /// Apply a heading style with ``TextualNamespace/headingStyle(_:)`` or through a bundled + /// ``StructuredText/Style``. + public protocol HeadingStyle: DynamicProperty { + associatedtype Body: View + + /// Creates a view that represents a heading. + @MainActor @ViewBuilder func makeBody(configuration: Self.Configuration) -> Self.Body + + typealias Configuration = HeadingStyleConfiguration + } +} + +extension EnvironmentValues { + @usableFromInline + @Entry var headingStyle: any StructuredText.HeadingStyle = .default +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/ListItemStyle.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/ListItemStyle.swift new file mode 100644 index 000000000..1e4d1f8de --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/ListItemStyle.swift @@ -0,0 +1,51 @@ +import SwiftUI + +extension StructuredText { + /// The properties of a list item passed to a `ListItemStyle`. + public struct ListItemStyleConfiguration { + /// A type-erased view that contains the list marker (bullet, number, and so on). + public struct Marker: View { + init(_ marker: some View) { + if let marker = marker as? AnyView { + self.body = marker + } else { + self.body = AnyView(marker) + } + } + public let body: AnyView + } + + /// A type-erased view that contains the list item content. + public struct Block: View { + init(_ block: some View) { + self.body = AnyView(block) + } + public let body: AnyView + } + + /// The marker view. + public let marker: Marker + /// The list item content. + public let block: Block + /// The indentation level of the list item within the document structure. + public let indentationLevel: Int + } + + /// A style that controls how `StructuredText` lays out list items. + /// + /// Apply a list item style with ``TextualNamespace/listItemStyle(_:)`` or through a bundled + /// ``StructuredText/Style``. + public protocol ListItemStyle: DynamicProperty { + associatedtype Body: View + + /// Creates a view that represents a list item. + @MainActor @ViewBuilder func makeBody(configuration: Self.Configuration) -> Self.Body + + typealias Configuration = ListItemStyleConfiguration + } +} + +extension EnvironmentValues { + @usableFromInline + @Entry var listItemStyle: any StructuredText.ListItemStyle = .default +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/OrderedListMarker.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/OrderedListMarker.swift new file mode 100644 index 000000000..30bdaeb7c --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/OrderedListMarker.swift @@ -0,0 +1,148 @@ +import SwiftUI + +extension StructuredText { + /// The properties of an ordered-list marker passed to an `OrderedListMarker`. + public struct OrderedListMarkerConfiguration { + /// The indentation level of the list within the document structure. + public let indentationLevel: Int + /// The item number for this marker. + public let ordinal: Int + } + + /// A marker view used for ordered list items. + /// + /// Apply an ordered list marker with ``TextualNamespace/orderedListMarker(_:)`` or through a bundled + /// ``StructuredText/Style``. + public protocol OrderedListMarker: DynamicProperty { + associatedtype Body: View + + /// Creates a view that represents the marker for a list item. + @MainActor @ViewBuilder func makeBody(configuration: Self.Configuration) -> Self.Body + + typealias Configuration = OrderedListMarkerConfiguration + } +} + +extension EnvironmentValues { + @usableFromInline + @Entry var orderedListMarker: any StructuredText.OrderedListMarker = .decimal +} + +// MARK: - Decimal + +extension StructuredText { + /// A list marker that displays an item’s ordinal as a decimal number. + public struct DecimalListMarker: OrderedListMarker { + private let minWidth: FontScaled + + /// Creates a decimal marker. + /// + /// - Parameter minWidth: A font-relative minimum width for the marker, useful for aligning + /// multi-digit ordinals. + public init(minWidth: FontScaled = .fontScaled(1.5)) { + self.minWidth = minWidth + } + + public func makeBody(configuration: Configuration) -> some View { + Text("\(configuration.ordinal).") + .monospacedDigit() + .textual.frame(minWidth: minWidth, alignment: .trailing) + } + } +} + +extension StructuredText.OrderedListMarker where Self == StructuredText.DecimalListMarker { + /// The default decimal ordered-list marker. + public static var decimal: Self { + .init() + } +} + +// MARK: - Ordinal mappings + +extension StructuredText { + /// A list marker that formats the item ordinal using a custom string transform. + public struct MapOrdinalListMarker: OrderedListMarker { + private let minWidth: FontScaled + private let transform: (Int) -> String + + /// Creates a marker that formats ordinals with `transform`. + /// + /// - Parameters: + /// - minWidth: A font-relative minimum width for the marker, useful for aligning markers. + /// - transform: A closure that converts the ordinal into a string (without the trailing period). + public init( + minWidth: FontScaled = .fontScaled(1.5), transform: @escaping (Int) -> String + ) { + self.minWidth = minWidth + self.transform = transform + } + + public func makeBody(configuration: Configuration) -> some View { + Text("\(transform(configuration.ordinal)).") + .textual.frame(minWidth: minWidth, alignment: .trailing) + } + } +} + +extension StructuredText.OrderedListMarker where Self == StructuredText.MapOrdinalListMarker { + /// An ordered-list marker that uses uppercase Roman numerals. + public static var upperRoman: Self { + .init { $0.roman() } + } + + /// An ordered-list marker that uses lowercase Roman numerals. + public static var lowerRoman: Self { + .init { $0.roman().lowercased() } + } + + /// An ordered-list marker that uses uppercase letters. + public static var upperAlpha: Self { + .init(transform: \.upperAlpha) + } + + /// An ordered-list marker that uses lowercase letters. + public static var lowerAlpha: Self { + .init(transform: \.lowerAlpha) + } +} + +// MARK: - Helpers + +extension Int { + fileprivate var lowerAlpha: String { + guard self > 0, self <= 26 else { + return String(self) + } + return String(UnicodeScalar(UInt8(96 + self))) + } + + fileprivate var upperAlpha: String { + guard self > 0, self <= 26 else { + return String(self) + } + return String(UnicodeScalar(UInt8(64 + self))) + } + + fileprivate func roman() -> String { + guard self > 0, self < 4000 else { + return "\(self)" + } + + let decimals = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1] + let numerals = ["M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I"] + + var number = self + var result = "" + + for (decimal, numeral) in zip(decimals, numerals) { + let repeats = number / decimal + if repeats > 0 { + result += String(repeating: numeral, count: repeats) + } + number = number % decimal + } + + return result + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Overflow.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Overflow.swift new file mode 100644 index 000000000..ab4f8db57 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Overflow.swift @@ -0,0 +1,115 @@ +import SwiftUI + +/// Controls how content behaves when it overflows horizontally. +public enum OverflowMode: Hashable { + /// Wraps content to fit the available width. + case wrap + /// Allows horizontal scrolling. + case scroll +} + +/// Describes the current overflow behavior and available layout metrics. +public enum OverflowState: Hashable { + /// Wraps content to fit the available width. + case wrap + /// Scrolls horizontally. The container width is provided when available. + case scroll(containerWidth: CGFloat?) + + /// The scroll container width when available; otherwise `nil`. + public var containerWidth: CGFloat? { + guard case .scroll(let containerWidth) = self else { + return nil + } + return containerWidth + } +} + +/// A container that adapts to the current ``OverflowMode``. +/// +/// `Overflow` handles content that overflows horizontally. It can switch +/// between wrapping and horizontal scrolling based on an environment value. +/// +/// You can set the mode using the ``TextualNamespace/overflowMode(_:)`` modifier. The default is +/// ``OverflowMode/scroll``. +/// +/// - Note: You should always use `Overflow` if your custom style needs horizontal scrolling. +/// Using a horizontal `ScrollView` directly will interfere with text selection gestures. +public struct Overflow: View { + @Environment(\.overflowMode) private var mode + @State private var containerWidth: CGFloat? + @State private var contentHeight: CGFloat? + + private let content: (OverflowState) -> Content + + /// Creates an overflow container. + public init(@ViewBuilder content: @escaping () -> Content) { + self.init { _ in + content() + } + } + + /// Creates an overflow container that exposes the current overflow state. + public init(@ViewBuilder content: @escaping (_ state: OverflowState) -> Content) { + self.content = content + } + + public var body: some View { + switch mode { + case .wrap: + content(.wrap) + .frame(maxWidth: .infinity, alignment: .leading) + + case .scroll: + ScrollView(.horizontal) { + ZStack { + // Update the scroll view height when the content height changes + Color.clear + .frame(minHeight: contentHeight) + content(.scroll(containerWidth: containerWidth)) + .onGeometryChange(for: CGFloat.self, of: \.size.height) { + contentHeight = $0 + } + // Make text selection local in scrollable regions + .modifier(TextSelectionInteraction()) + .transformPreference(Text.LayoutKey.self) { value in + value = [] + } + } + } + .onScrollGeometryChange(for: CGFloat.self, of: \.containerSize.width) { + containerWidth = $1 + } + // Propagate gesture exclusion area + .background( + GeometryReader { geometry in + Color.clear + .preference( + key: OverflowFrameKey.self, + value: [geometry.frame(in: .textContainer)] + ) + } + ) + } + } +} + +extension EnvironmentValues { + @usableFromInline + @Entry var overflowMode = OverflowMode.scroll +} + +/// Excludes an embedded control from the enclosing native text-selection overlay. +extension TextualNamespace where Base: View { + /// Apply to a control inside Textual content after sizing its actual hit target. + /// The control keeps its own gestures while surrounding text remains selectable. + public func textSelectionInteractionExcluded() -> some View { + base.background( + GeometryReader { geometry in + Color.clear.preference( + key: OverflowFrameKey.self, + value: [geometry.frame(in: .textContainer)] + ) + } + ) + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/ParagraphStyle.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/ParagraphStyle.swift new file mode 100644 index 000000000..33f09a799 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/ParagraphStyle.swift @@ -0,0 +1,21 @@ +import SwiftUI + +extension StructuredText { + /// A style that controls how `StructuredText` renders paragraphs. + /// + /// You can set a paragraph style using the ``TextualNamespace/paragraphStyle(_:)`` modifier + /// or through a bundled ``StructuredText/Style``. + public protocol ParagraphStyle: DynamicProperty { + associatedtype Body: View + + /// Creates a view that represents a paragraph. + @MainActor @ViewBuilder func makeBody(configuration: Self.Configuration) -> Self.Body + + typealias Configuration = BlockStyleConfiguration + } +} + +extension EnvironmentValues { + @usableFromInline + @Entry var paragraphStyle: any StructuredText.ParagraphStyle = .default +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Style.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Style.swift new file mode 100644 index 000000000..ad81b56d3 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/Style.swift @@ -0,0 +1,44 @@ +import SwiftUI + +extension StructuredText { + /// A collection of styles that control how `StructuredText` renders each block type. + /// + /// Use this protocol to bundle a consistent look (paragraphs, headings, lists, code blocks, + /// tables, and so on) and apply it using the ``TextualNamespace/structuredTextStyle(_:)`` + /// modifier. + public protocol Style { + associatedtype HeadingStyle: StructuredText.HeadingStyle + associatedtype ParagraphStyle: StructuredText.ParagraphStyle + associatedtype BlockQuoteStyle: StructuredText.BlockQuoteStyle + associatedtype CodeBlockStyle: StructuredText.CodeBlockStyle + associatedtype ListItemStyle: StructuredText.ListItemStyle + associatedtype UnorderedListMarker: StructuredText.UnorderedListMarker + associatedtype OrderedListMarker: StructuredText.OrderedListMarker + associatedtype TableStyle: StructuredText.TableStyle + associatedtype TableCellStyle: StructuredText.TableCellStyle + associatedtype ThematicBreakStyle: StructuredText.ThematicBreakStyle + + /// The inline style used for spans within a block. + var inlineStyle: InlineStyle { get } + /// The style used for headings. + var headingStyle: HeadingStyle { get } + /// The style used for paragraphs. + var paragraphStyle: ParagraphStyle { get } + /// The style used for block quotes. + var blockQuoteStyle: BlockQuoteStyle { get } + /// The style used for code blocks. + var codeBlockStyle: CodeBlockStyle { get } + /// The style used for list items. + var listItemStyle: ListItemStyle { get } + /// The marker used for unordered lists. + var unorderedListMarker: UnorderedListMarker { get } + /// The marker used for ordered lists. + var orderedListMarker: OrderedListMarker { get } + /// The style used for tables. + var tableStyle: TableStyle { get } + /// The style used for individual table cells. + var tableCellStyle: TableCellStyle { get } + /// The style used for thematic breaks. + var thematicBreakStyle: ThematicBreakStyle { get } + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/TableCellStyle.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/TableCellStyle.swift new file mode 100644 index 000000000..52c343cff --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/TableCellStyle.swift @@ -0,0 +1,41 @@ +import SwiftUI + +extension StructuredText { + /// The properties of a table cell passed to a `TableCellStyle`. + public struct TableCellStyleConfiguration { + /// A type-erased view that contains the cell content. + public struct Label: View { + init(_ label: some View) { + self.body = AnyView(label) + } + public let body: AnyView + } + + /// The cell content. + public let label: Label + /// The indentation level of the table within the document structure. + public let indentationLevel: Int + /// The row index of the cell. + public let row: Int + /// The column index of the cell. + public let column: Int + } + + /// A style that controls how `StructuredText` renders individual table cells. + /// + /// Apply a table cell style using the ``TextualNamespace/tableCellStyle(_:)`` modifier or + /// through a bundled ``StructuredText/Style``. + public protocol TableCellStyle: DynamicProperty { + associatedtype Body: View + + /// Creates a view that represents a table cell. + @MainActor @ViewBuilder func makeBody(configuration: Self.Configuration) -> Self.Body + + typealias Configuration = TableCellStyleConfiguration + } +} + +extension EnvironmentValues { + @usableFromInline + @Entry var tableCellStyle: any StructuredText.TableCellStyle = .default +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/TableLayout.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/TableLayout.swift new file mode 100644 index 000000000..88886427f --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/TableLayout.swift @@ -0,0 +1,150 @@ +import SwiftUI + +extension StructuredText { + /// A layout description for a rendered table. + /// + /// `StructuredText` computes a `TableLayout` from the measured sizes of each cell and provides + /// it to ``StructuredText/TableStyle`` when rendering table backgrounds and overlays. Styles + /// can use it to draw dividers or row backgrounds that align with the table grid. + public struct TableLayout: Hashable { + private struct Row: Hashable { + var minY: CGFloat + var height: CGFloat + } + + private struct Column: Hashable { + var minX: CGFloat + var width: CGFloat + } + + /// The bounding rectangle that contains the whole table. + public let bounds: CGRect + + /// The valid row indices for this table. + public var rowIndices: Range { rows.indices } + + /// The valid column indices for this table. + public var columnIndices: Range { columns.indices } + + /// The number of rows in the table. + public var numberOfRows: Int { rows.count } + + /// The number of columns in the table. + public var numberOfColumns: Int { columns.count } + + private let rows: [Row] + private let columns: [Column] + + init(_ cellBounds: [TableCell.Identifier: Anchor], geometry: GeometryProxy) { + self.init(cellBounds.mapValues { geometry[$0] }) + } + + init(_ cellBounds: [TableCell.Identifier: CGRect] = [:]) { + let rowCount = Set(cellBounds.keys.map(\.row)).count + let columnCount = Set(cellBounds.keys.map(\.column)).count + + var rows = Array( + repeating: Row(minY: .greatestFiniteMagnitude, height: 0), + count: rowCount + ) + var columns = Array( + repeating: Column(minX: .greatestFiniteMagnitude, width: 0), + count: columnCount + ) + + for (identifier, bounds) in cellBounds { + let row = identifier.row + let column = identifier.column + + rows[row].minY = min(rows[row].minY, bounds.minY) + rows[row].height = max(rows[row].height, bounds.height) + + columns[column].minX = min(columns[column].minX, bounds.minX) + columns[column].width = max(columns[column].width, bounds.width) + } + + self.rows = rows + self.columns = columns + self.bounds = cellBounds.values.reduce(CGRect.null, CGRectUnion) + } + + /// Returns the bounds of the cell at the given row and column. + public func cellBounds(row: Int, column: Int) -> CGRect { + CGRect( + origin: .init(x: columns[column].minX, y: rows[row].minY), + size: CGSize(width: columns[column].width, height: rows[row].height) + ) + } + + /// Returns the bounds of the given row. + public func rowBounds(_ row: Int) -> CGRect { + columnIndices + .compactMap { cellBounds(row: row, column: $0) } + .reduce(.null, CGRectUnion) + } + + /// Returns the bounds of the given column. + public func columnBounds(_ column: Int) -> CGRect { + rowIndices + .compactMap { cellBounds(row: $0, column: column) } + .reduce(.null, CGRectUnion) + } + + /// Returns rectangles representing vertical dividers between columns. + public func verticalDividers() -> [CGRect] { + guard columns.count > 1 else { return [] } + + var dividers: [CGRect] = [] + + for column in columnIndices.dropLast() { + let leftMaxX = columns[column].minX + columns[column].width + let rightMinX = columns[column + 1].minX + + let width = rightMinX - leftMaxX + guard width > 0 else { continue } + + dividers.append( + CGRect( + x: leftMaxX, + y: bounds.minY, + width: width, + height: bounds.height + ) + ) + } + + return dividers + } + + /// Returns rectangles representing horizontal dividers between rows. + public func horizontalDividers() -> [CGRect] { + guard rows.count > 1 else { return [] } + + var dividers: [CGRect] = [] + + for row in rowIndices.dropLast() { + let topMaxY = rows[row].minY + rows[row].height + let bottomMinY = rows[row + 1].minY + + let height = bottomMinY - topMaxY + guard height > 0 else { continue } + + dividers.append( + CGRect( + x: bounds.minX, + y: topMaxY, + width: bounds.width, + height: height + ) + ) + } + + return dividers + } + + /// Returns rectangles for all dividers in the table. + public func dividers() -> [CGRect] { + horizontalDividers() + verticalDividers() + } + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/TableStyle.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/TableStyle.swift new file mode 100644 index 000000000..1d84d43bc --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/TableStyle.swift @@ -0,0 +1,39 @@ +import SwiftUI + +extension StructuredText { + /// The properties of a table passed to a `TableStyle`. + public struct TableStyleConfiguration { + /// A type-erased view that contains the table content. + public struct Label: View { + init(_ label: some View) { + self.body = AnyView(label) + } + public let body: AnyView + } + + /// The table content. + public let label: Label + + /// The indentation level of the table within the document structure. + public let indentationLevel: Int + } + + /// A style that controls how `StructuredText` renders tables. + /// + /// You can apply a table style using the ``TextualNamespace/tableStyle(_:)`` modifier + /// or through a bundled ``StructuredText/Style``. + public protocol TableStyle: DynamicProperty { + associatedtype Body: View + + /// Creates a view that represents a table. + @MainActor @ViewBuilder + func makeBody(configuration: Self.Configuration) -> Self.Body + + typealias Configuration = TableStyleConfiguration + } +} + +extension EnvironmentValues { + @usableFromInline + @Entry var tableStyle: any StructuredText.TableStyle = .default +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/ThematicBreakStyle.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/ThematicBreakStyle.swift new file mode 100644 index 000000000..1b6c9430f --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/ThematicBreakStyle.swift @@ -0,0 +1,21 @@ +import SwiftUI + +extension StructuredText { + /// A style that controls how `StructuredText` renders thematic breaks. + /// + /// You can apply a thematic break style using the ``TextualNamespace/thematicBreakStyle(_:)`` modifier + /// or through a bundled ``StructuredText/Style``. + public protocol ThematicBreakStyle: DynamicProperty { + associatedtype Body: View + + /// Creates a view that represents a thematic break. + @MainActor @ViewBuilder func makeBody(configuration: Self.Configuration) -> Self.Body + + typealias Configuration = BlockStyleConfiguration + } +} + +extension EnvironmentValues { + @usableFromInline + @Entry var thematicBreakStyle: any StructuredText.ThematicBreakStyle = .divider +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/UnorderedListMarker.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/UnorderedListMarker.swift new file mode 100644 index 000000000..a6e51fe79 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/StructuredText/Style/UnorderedListMarker.swift @@ -0,0 +1,136 @@ +import SwiftUI + +extension StructuredText { + /// The properties of an unordered-list marker passed to an `UnorderedListMarker`. + public struct UnorderedListMarkerConfiguration { + /// The indentation level of the list within the document structure. + public let indentationLevel: Int + } + + /// A marker view used for unordered list items (for example, a bullet). + /// + /// You can apply an unordered list marker using the ``TextualNamespace/unorderedListMarker(_:)`` modifier + /// or through a bundled ``StructuredText/Style``. + public protocol UnorderedListMarker: DynamicProperty { + associatedtype Body: View + + /// Creates a view that represents the marker for a list item. + @MainActor @ViewBuilder func makeBody(configuration: Self.Configuration) -> Self.Body + + typealias Configuration = UnorderedListMarkerConfiguration + } +} + +extension EnvironmentValues { + @usableFromInline + @Entry var unorderedListMarker: any StructuredText.UnorderedListMarker = .disc +} + +// MARK: - Symbol + +extension StructuredText { + /// A list marker that uses an SF Symbol. + public struct SymbolListMarker: UnorderedListMarker { + private let symbolName: String + private let scale: CGFloat + private let minWidth: FontScaled + + /// Creates a symbol marker. + /// + /// - Parameters: + /// - symbolName: The SF Symbol name. + /// - scale: A font scale applied to the symbol. + /// - minWidth: A font-relative minimum width for the marker. + public init( + symbolName: String, + scale: CGFloat = 1, + minWidth: FontScaled = .fontScaled(1.5) + ) { + self.symbolName = symbolName + self.scale = scale + self.minWidth = minWidth + } + + public func makeBody(configuration: Configuration) -> some View { + SwiftUI.Image(systemName: symbolName) + .textual.fontScale(scale) + .textual.frame(minWidth: minWidth, alignment: .trailing) + } + } +} + +extension StructuredText.UnorderedListMarker where Self == StructuredText.SymbolListMarker { + /// A filled-circle marker. + public static var disc: Self { + .init(symbolName: "circle.fill", scale: 0.33) + } + + /// An outlined-circle marker. + public static var circle: Self { + .init(symbolName: "circle", scale: 0.33) + } + + /// A filled-square marker. + public static var square: Self { + .init(symbolName: "square.fill", scale: 0.33) + } +} + +// MARK: - Hierarchical + +extension StructuredText { + /// A marker that cycles through a list of symbol markers based on indentation level. + public struct HierarchicalSymbolListMarker: UnorderedListMarker { + private let markers: [SymbolListMarker] + + /// Creates a hierarchical marker from a variadic list of markers. + public init(_ markers: SymbolListMarker...) { + self.init(markers: markers) + } + + init(markers: [SymbolListMarker]) { + self.markers = markers + } + + public func makeBody(configuration: Configuration) -> some View { + markers[(configuration.indentationLevel - 1) % markers.count] + .makeBody(configuration: configuration) + } + } +} + +extension StructuredText.UnorderedListMarker +where Self == StructuredText.HierarchicalSymbolListMarker { + /// Creates a hierarchical marker from a variadic list of markers. + public static func hierarchical(_ markers: StructuredText.SymbolListMarker...) -> Self { + .init(markers: markers) + } +} + +// MARK: - Dash + +extension StructuredText { + /// A list marker that renders a dash (`-`). + public struct DashListMarker: UnorderedListMarker { + private let minWidth: FontScaled + + /// Creates a dash marker. + /// + /// - Parameter minWidth: A font-relative minimum width for the marker. + public init(minWidth: FontScaled = .fontScaled(1.5)) { + self.minWidth = minWidth + } + + public func makeBody(configuration: Configuration) -> some View { + Text("-") + .textual.frame(minWidth: minWidth, alignment: .trailing) + } + } +} + +extension StructuredText.UnorderedListMarker where Self == StructuredText.DashListMarker { + /// The default dash marker. + public static var dash: Self { + .init() + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/TextEnvironmentValues.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/TextEnvironmentValues.swift new file mode 100644 index 000000000..b6b30f615 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/TextEnvironmentValues.swift @@ -0,0 +1,68 @@ +import SwiftUI + +/// A snapshot of SwiftUI text environment values. +/// +/// Textual passes `TextEnvironmentValues` to APIs that need to resolve styling or sizing based on +/// the current environment, such as ``TextProperty`` and ``Attachment``. +public struct TextEnvironmentValues: Hashable, Sendable { + /// The current font in the environment. + public var font: Font? + + /// The current dynamic type size in the environment. + public var dynamicTypeSize: DynamicTypeSize + + /// The current legibility weight in the environment. + public var legibilityWeight: LegibilityWeight? + + /// Properties that control how Textual renders emoji. + public var emojiProperties: EmojiProperties + + /// Properties that control how Textual renders math expressions. + public var mathProperties: MathProperties + + /// The current color scheme in the environment. + public var colorScheme: ColorScheme + + /// The current color scheme contrast in the environment. + public var colorSchemeContrast: ColorSchemeContrast + + /// A smaller environment value set used for color resolution. + public var colorEnvironment: ColorEnvironmentValues { + .init( + colorScheme: colorScheme, + colorSchemeContrast: colorSchemeContrast + ) + } + + init( + font: Font? = nil, + dynamicTypeSize: DynamicTypeSize = .large, + legibilityWeight: LegibilityWeight? = nil, + emojiProperties: EmojiProperties = .init(), + mathProperties: MathProperties = .init(), + colorScheme: ColorScheme = .light, + colorSchemeContrast: ColorSchemeContrast = .standard + ) { + self.font = font + self.dynamicTypeSize = dynamicTypeSize + self.legibilityWeight = legibilityWeight + self.emojiProperties = emojiProperties + self.mathProperties = mathProperties + self.colorScheme = colorScheme + self.colorSchemeContrast = colorSchemeContrast + } +} + +extension EnvironmentValues { + var textEnvironment: TextEnvironmentValues { + .init( + font: font, + dynamicTypeSize: dynamicTypeSize, + legibilityWeight: legibilityWeight, + emojiProperties: emojiProperties, + mathProperties: mathProperties, + colorScheme: colorScheme, + colorSchemeContrast: colorSchemeContrast + ) + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Color/BackgroundColorProperty.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Color/BackgroundColorProperty.swift new file mode 100644 index 000000000..72bd58e16 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Color/BackgroundColorProperty.swift @@ -0,0 +1,30 @@ +import SwiftUI + +/// Sets a background color attribute. +public struct BackgroundColorProperty: TextProperty { + private let color: DynamicColor + + /// Creates a background color property using a dynamic color. + public init(_ color: DynamicColor) { + self.color = color + } + + public func apply( + in attributes: inout AttributeContainer, + environment: TextEnvironmentValues + ) { + attributes.backgroundColor = color.bestMatch(for: environment.colorEnvironment) + } +} + +extension TextProperty where Self == BackgroundColorProperty { + /// Sets the background color using a dynamic color. + public static func backgroundColor(_ color: DynamicColor) -> Self { + BackgroundColorProperty(color) + } + + /// Sets the background color using a fixed SwiftUI color. + public static func backgroundColor(_ color: Color) -> Self { + BackgroundColorProperty(DynamicColor(color)) + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Color/ForegroundColorProperty.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Color/ForegroundColorProperty.swift new file mode 100644 index 000000000..ec8953850 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Color/ForegroundColorProperty.swift @@ -0,0 +1,30 @@ +import SwiftUI + +/// Sets a foreground (text) color attribute. +public struct ForegroundColorProperty: TextProperty { + private let color: DynamicColor + + /// Creates a foreground color property using a dynamic color. + public init(_ color: DynamicColor) { + self.color = color + } + + public func apply( + in attributes: inout AttributeContainer, + environment: TextEnvironmentValues + ) { + attributes.foregroundColor = color.bestMatch(for: environment.colorEnvironment) + } +} + +extension TextProperty where Self == ForegroundColorProperty { + /// Sets the foreground color using a dynamic color. + public static func foregroundColor(_ color: DynamicColor) -> Self { + ForegroundColorProperty(color) + } + + /// Sets the foreground color using a fixed SwiftUI color. + public static func foregroundColor(_ color: Color) -> Self { + ForegroundColorProperty(DynamicColor(color)) + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Font/BoldProperty.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Font/BoldProperty.swift new file mode 100644 index 000000000..c45a2caac --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Font/BoldProperty.swift @@ -0,0 +1,17 @@ +import SwiftUI + +/// Applies a bold font trait. +public struct BoldProperty: TextProperty { + /// Creates a bold property. + public init() {} + + public func apply(in attributes: inout AttributeContainer, environment: TextEnvironmentValues) { + let font = attributes.font ?? environment.font ?? .body + attributes.font = font.bold() + } +} + +extension TextProperty where Self == BoldProperty { + /// Applies a bold font trait. + public static var bold: Self { .init() } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Font/FontLeadingProperty.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Font/FontLeadingProperty.swift new file mode 100644 index 000000000..809f0785b --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Font/FontLeadingProperty.swift @@ -0,0 +1,23 @@ +import SwiftUI + +/// Applies a font leading setting. +public struct FontLeadingProperty: TextProperty { + private let leading: Font.Leading + + /// Creates a font leading property. + public init(_ leading: Font.Leading) { + self.leading = leading + } + + public func apply(in attributes: inout AttributeContainer, environment: TextEnvironmentValues) { + let font = attributes.font ?? environment.font ?? .body + attributes.font = font.leading(leading) + } +} + +extension TextProperty where Self == FontLeadingProperty { + /// Applies the given leading setting to the font. + public static func fontLeading(_ leading: Font.Leading) -> Self { + FontLeadingProperty(leading) + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Font/FontProperty.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Font/FontProperty.swift new file mode 100644 index 000000000..948fbae90 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Font/FontProperty.swift @@ -0,0 +1,27 @@ +import SwiftUI + +/// Replaces the font attribute. +/// +/// Pass `nil` to clear any explicit font and let the surrounding style decide. +public struct FontProperty: TextProperty { + private let font: Font? + + /// Creates a font property. + public init(_ font: Font?) { + self.font = font + } + + public func apply( + in attributes: inout AttributeContainer, + environment _: TextEnvironmentValues + ) { + attributes.font = font + } +} + +extension TextProperty where Self == FontProperty { + /// Sets the font attribute. + public static func font(_ font: Font?) -> Self { + FontProperty(font) + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Font/FontScaleProperty.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Font/FontScaleProperty.swift new file mode 100644 index 000000000..eb9a17fec --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Font/FontScaleProperty.swift @@ -0,0 +1,35 @@ +import Foundation + +/// Scales the current font by the given factor. +public struct FontScaleProperty: TextProperty { + private let scale: CGFloat + + /// Creates a font scale property. + public init(_ scale: CGFloat) { + self.scale = scale + } + + public func apply(in attributes: inout AttributeContainer, environment: TextEnvironmentValues) { + let font = attributes.font ?? environment.font ?? .body + #if compiler(>=6.2) + if #available(iOS 26.0, macOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) { + attributes.font = font.scaled(by: scale) + } else if var provider = font.provider() { + provider.scale = scale + attributes.font = provider.resolve(in: environment) + } + #else + if var provider = font.provider() { + provider.scale = scale + attributes.font = provider.resolve(in: environment) + } + #endif + } +} + +extension TextProperty where Self == FontScaleProperty { + /// Scales the font by the given factor. + public static func fontScale(_ scale: CGFloat) -> Self { + FontScaleProperty(scale) + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Font/FontWeightProperty.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Font/FontWeightProperty.swift new file mode 100644 index 000000000..7dcd94f9a --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Font/FontWeightProperty.swift @@ -0,0 +1,23 @@ +import SwiftUI + +/// Applies a font weight. +public struct FontWeightProperty: TextProperty { + private let weight: Font.Weight + + /// Creates a font weight property. + public init(_ weight: Font.Weight) { + self.weight = weight + } + + public func apply(in attributes: inout AttributeContainer, environment: TextEnvironmentValues) { + let font = attributes.font ?? environment.font ?? .body + attributes.font = font.weight(weight) + } +} + +extension TextProperty where Self == FontWeightProperty { + /// Applies the given weight to the font. + public static func fontWeight(_ weight: Font.Weight) -> Self { + FontWeightProperty(weight) + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Font/FontWidthProperty.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Font/FontWidthProperty.swift new file mode 100644 index 000000000..26bdd6d5e --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Font/FontWidthProperty.swift @@ -0,0 +1,23 @@ +import SwiftUI + +/// Applies a font width (condensed/expanded). +public struct FontWidthProperty: TextProperty { + private let width: Font.Width + + /// Creates a font width property. + public init(_ width: Font.Width) { + self.width = width + } + + public func apply(in attributes: inout AttributeContainer, environment: TextEnvironmentValues) { + let font = attributes.font ?? environment.font ?? .body + attributes.font = font.width(width) + } +} + +extension TextProperty where Self == FontWidthProperty { + /// Applies the given width to the font. + public static func fontWidth(_ width: Font.Width) -> Self { + FontWidthProperty(width) + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Font/ItalicProperty.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Font/ItalicProperty.swift new file mode 100644 index 000000000..26226756d --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Font/ItalicProperty.swift @@ -0,0 +1,17 @@ +import SwiftUI + +/// Applies an italic font trait. +public struct ItalicProperty: TextProperty { + /// Creates an italic property. + public init() {} + + public func apply(in attributes: inout AttributeContainer, environment: TextEnvironmentValues) { + let font = attributes.font ?? environment.font ?? .body + attributes.font = font.italic() + } +} + +extension TextProperty where Self == ItalicProperty { + /// Applies an italic font trait. + public static var italic: Self { .init() } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Font/LowercaseSmallCapsProperty.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Font/LowercaseSmallCapsProperty.swift new file mode 100644 index 000000000..80f6205da --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Font/LowercaseSmallCapsProperty.swift @@ -0,0 +1,17 @@ +import SwiftUI + +/// Applies a lowercase small caps font variant. +public struct LowercaseSmallCapsProperty: TextProperty { + /// Creates a lowercase small caps property. + public init() {} + + public func apply(in attributes: inout AttributeContainer, environment: TextEnvironmentValues) { + let font = attributes.font ?? environment.font ?? .body + attributes.font = font.lowercaseSmallCaps() + } +} + +extension TextProperty where Self == LowercaseSmallCapsProperty { + /// Applies a lowercase small caps font variant. + public static var lowercaseSmallCaps: Self { .init() } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Font/MonospacedDigitProperty.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Font/MonospacedDigitProperty.swift new file mode 100644 index 000000000..69755e413 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Font/MonospacedDigitProperty.swift @@ -0,0 +1,17 @@ +import SwiftUI + +/// Uses monospaced digits. +public struct MonospacedDigitProperty: TextProperty { + /// Creates a monospaced digit property. + public init() {} + + public func apply(in attributes: inout AttributeContainer, environment: TextEnvironmentValues) { + let font = attributes.font ?? environment.font ?? .body + attributes.font = font.monospacedDigit() + } +} + +extension TextProperty where Self == MonospacedDigitProperty { + /// Uses monospaced digits. + public static var monospacedDigit: Self { .init() } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Font/MonospacedProperty.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Font/MonospacedProperty.swift new file mode 100644 index 000000000..a65b402ff --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Font/MonospacedProperty.swift @@ -0,0 +1,17 @@ +import SwiftUI + +/// Uses a monospaced font design. +public struct MonospacedProperty: TextProperty { + /// Creates a monospaced property. + public init() {} + + public func apply(in attributes: inout AttributeContainer, environment: TextEnvironmentValues) { + let font = attributes.font ?? environment.font ?? .body + attributes.font = font.monospaced() + } +} + +extension TextProperty where Self == MonospacedProperty { + /// Uses a monospaced font design. + public static var monospaced: Self { .init() } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Font/SmallCapsProperty.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Font/SmallCapsProperty.swift new file mode 100644 index 000000000..c7bc5fbd6 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Font/SmallCapsProperty.swift @@ -0,0 +1,17 @@ +import SwiftUI + +/// Applies a small caps font variant. +public struct SmallCapsProperty: TextProperty { + /// Creates a small caps property. + public init() {} + + public func apply(in attributes: inout AttributeContainer, environment: TextEnvironmentValues) { + let font = attributes.font ?? environment.font ?? .body + attributes.font = font.smallCaps() + } +} + +extension TextProperty where Self == SmallCapsProperty { + /// Applies a small caps font variant. + public static var smallCaps: Self { .init() } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Font/UppercaseSmallCapsProperty.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Font/UppercaseSmallCapsProperty.swift new file mode 100644 index 000000000..e9dfcf0d1 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Font/UppercaseSmallCapsProperty.swift @@ -0,0 +1,17 @@ +import SwiftUI + +/// Applies an uppercase small caps font variant. +public struct UppercaseSmallCapsProperty: TextProperty { + /// Creates an uppercase small caps property. + public init() {} + + public func apply(in attributes: inout AttributeContainer, environment: TextEnvironmentValues) { + let font = attributes.font ?? environment.font ?? .body + attributes.font = font.uppercaseSmallCaps() + } +} + +extension TextProperty where Self == UppercaseSmallCapsProperty { + /// Applies an uppercase small caps font variant. + public static var uppercaseSmallCaps: Self { .init() } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/LineStyle/StrikethroughStyleProperty.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/LineStyle/StrikethroughStyleProperty.swift new file mode 100644 index 000000000..fb47374bb --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/LineStyle/StrikethroughStyleProperty.swift @@ -0,0 +1,25 @@ +import SwiftUI + +/// Applies a strikethrough style attribute. +public struct StrikethroughStyleProperty: TextProperty { + private let style: Text.LineStyle + + /// Creates a strikethrough style property. + public init(_ style: Text.LineStyle) { + self.style = style + } + + public func apply( + in attributes: inout AttributeContainer, + environment _: TextEnvironmentValues + ) { + attributes.strikethroughStyle = style + } +} + +extension TextProperty where Self == StrikethroughStyleProperty { + /// Strikes through text using the given line style. + public static func strikethroughStyle(_ style: Text.LineStyle) -> Self { + StrikethroughStyleProperty(style) + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/LineStyle/UnderlineStyleProperty.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/LineStyle/UnderlineStyleProperty.swift new file mode 100644 index 000000000..63c856e0a --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/LineStyle/UnderlineStyleProperty.swift @@ -0,0 +1,25 @@ +import SwiftUI + +/// Applies an underline style attribute. +public struct UnderlineStyleProperty: TextProperty { + private let style: Text.LineStyle + + /// Creates an underline style property. + public init(_ style: Text.LineStyle) { + self.style = style + } + + public func apply( + in attributes: inout AttributeContainer, + environment _: TextEnvironmentValues + ) { + attributes.underlineStyle = style + } +} + +extension TextProperty where Self == UnderlineStyleProperty { + /// Underlines text using the given line style. + public static func underlineStyle(_ style: Text.LineStyle) -> Self { + UnderlineStyleProperty(style) + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Space/BaselineOffsetProperty.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Space/BaselineOffsetProperty.swift new file mode 100644 index 000000000..ee5931abd --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Space/BaselineOffsetProperty.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Sets a baseline offset attribute. +public struct BaselineOffsetProperty: TextProperty { + private let offset: CGFloat + + /// Creates a baseline offset property. + public init(_ offset: CGFloat) { + self.offset = offset + } + + public func apply( + in attributes: inout AttributeContainer, + environment _: TextEnvironmentValues + ) { + attributes.baselineOffset = offset + } +} + +extension TextProperty where Self == BaselineOffsetProperty { + /// Sets the baseline offset. + public static func baselineOffset(_ offset: CGFloat) -> Self { + BaselineOffsetProperty(offset) + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Space/KerningProperty.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Space/KerningProperty.swift new file mode 100644 index 000000000..fa7e6c7b0 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Space/KerningProperty.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Sets a kern value attribute. +public struct KerningProperty: TextProperty { + private let kerning: CGFloat + + /// Creates a kerning property. + public init(_ kerning: CGFloat) { + self.kerning = kerning + } + + public func apply( + in attributes: inout AttributeContainer, + environment _: TextEnvironmentValues + ) { + attributes.kern = kerning + } +} + +extension TextProperty where Self == KerningProperty { + /// Sets the kern value. + public static func kerning(_ kerning: CGFloat) -> Self { + KerningProperty(kerning) + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Space/TrackingProperty.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Space/TrackingProperty.swift new file mode 100644 index 000000000..d12c339f0 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/Space/TrackingProperty.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Sets a tracking value attribute. +public struct TrackingProperty: TextProperty { + private let tracking: CGFloat + + /// Creates a tracking property. + public init(_ tracking: CGFloat) { + self.tracking = tracking + } + + public func apply( + in attributes: inout AttributeContainer, + environment _: TextEnvironmentValues + ) { + attributes.tracking = tracking + } +} + +extension TextProperty where Self == TrackingProperty { + /// Sets the tracking value. + public static func tracking(_ tracking: CGFloat) -> Self { + TrackingProperty(tracking) + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/TextProperty.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/TextProperty.swift new file mode 100644 index 000000000..5cc03aae9 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/TextProperty.swift @@ -0,0 +1,57 @@ +import Foundation + +/// A small, composable unit of inline styling. +/// +/// A `TextProperty` mutates an `AttributeContainer` for a portion of styled text. Textual applies +/// properties to the text spans it styles, and uses them to build higher-level styles like ``InlineStyle``. +public protocol TextProperty: Sendable, Hashable { + /// Applies this property to an attribute container. + /// + /// Implementations should be deterministic and side-effect free. + /// + /// - Parameters: + /// - attributes: The attribute container to modify. + /// - environment: The resolved text environment for the current view. + func apply(in attributes: inout AttributeContainer, environment: TextEnvironmentValues) +} + +/// A type-erased ``TextProperty``. +/// +/// Use `AnyTextProperty` when you need to store or pass text properties without exposing their +/// concrete types, or when you want to combine multiple properties into a single value. +/// +/// ```swift +/// let emphasis = AnyTextProperty( +/// .foregroundColor(.purple), +/// .fontWeight(.semibold) +/// ) +/// ``` +public struct AnyTextProperty: TextProperty { + let base: any TextProperty + + /// Creates a type-erased wrapper around a concrete property. + public init(_ base: some TextProperty) { + if let base = base as? AnyTextProperty { + self = base + } else { + self.base = base + } + } + + /// Creates a single property by composing multiple properties. + public init(_ properties: repeat each P) { + self.init(TupleTextProperty(repeat each properties)) + } + + public func apply(in attributes: inout AttributeContainer, environment: TextEnvironmentValues) { + base.apply(in: &attributes, environment: environment) + } + + public static func == (lhs: Self, rhs: Self) -> Bool { + AnyHashable(lhs.base) == AnyHashable(rhs.base) + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(base) + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/TupleTextProperty.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/TupleTextProperty.swift new file mode 100644 index 000000000..7c9f77903 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/TextProperty/TupleTextProperty.swift @@ -0,0 +1,28 @@ +import Foundation + +struct TupleTextProperty: TextProperty { + var properties: (repeat each P) + + init(_ properties: repeat each P) { + self.properties = (repeat each properties) + } + + func apply(in attributes: inout AttributeContainer, environment: TextEnvironmentValues) { + for property in repeat (each properties) { + property.apply(in: &attributes, environment: environment) + } + } + + static func == (lhs: Self, rhs: Self) -> Bool { + for (left, right) in repeat (each lhs.properties, each rhs.properties) { + guard left == right else { return false } + } + return true + } + + func hash(into hasher: inout Hasher) { + for property in repeat (each properties) { + hasher.combine(property) + } + } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/TextualNamespace.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/TextualNamespace.swift new file mode 100644 index 000000000..003df1c9e --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/TextualNamespace.swift @@ -0,0 +1,42 @@ +import Foundation + +/// A namespace for Textual-specific extensions. +/// +/// Use `TextualNamespace` as a customization point for constrained extensions. By placing +/// modifiers and helpers under `.textual`, Textual avoids adding lots of methods directly to +/// `View` and other widely used types. +/// +/// The general pattern is: +/// +/// ```swift +/// // Add APIs under `.textual` for a specific kind of base. +/// extension TextualNamespace where Base: View { +/// func myModifier() -> some View { ... } +/// } +/// ``` +/// +/// SwiftUI views get the namespace through the ``SwiftUICore/View/textual`` property. +/// Other types can opt into it by conforming to ``TextualCompatible``. +@frozen +public struct TextualNamespace { + @usableFromInline let base: Base + @inlinable public init(_ base: Base) { self.base = base } +} + +extension TextualNamespace: Sendable where Base: Sendable {} + +/// A type that opts into the `.textual` namespace. +/// +/// Types that conform to `TextualCompatible` gain `textual` helpers on both the instance and the +/// type. +public protocol TextualCompatible {} + +extension TextualCompatible { + /// The `TextualNamespace` type for this conforming type. + @inlinable public static var textual: TextualNamespace.Type { + TextualNamespace.self + } + + /// A `TextualNamespace` wrapper around this instance. + @inlinable public var textual: TextualNamespace { .init(self) } +} diff --git a/apps/maple-ios/Vendor/textual/Sources/Textual/View+Textual.swift b/apps/maple-ios/Vendor/textual/Sources/Textual/View+Textual.swift new file mode 100644 index 000000000..a74eada15 --- /dev/null +++ b/apps/maple-ios/Vendor/textual/Sources/Textual/View+Textual.swift @@ -0,0 +1,288 @@ +import SwiftUI + +extension View { + /// Provides access to Textual-specific modifiers. + /// + /// Use this property to access styling and configuration options for ``InlineText`` and + /// ``StructuredText``. For example: + /// + /// ```swift + /// StructuredText(markdown: "Hello, **world**!") + /// .textual.inlineStyle(.custom) + /// .textual.textSelection(.enabled) + /// ``` + @inlinable public var textual: TextualNamespace { .init(self) } +} + +extension TextualNamespace where Base: View { + /// Sets the spacing above and below the current block. + @MainActor public func blockSpacing(_ blockSpacing: StructuredText.BlockSpacing) -> some View { + base.preference(key: StructuredText.BlockSpacingKey.self, value: blockSpacing) + } + + /// Sets the spacing above and below the current block using a font-relative value. + @MainActor public func blockSpacing( + _ blockSpacing: FontScaled + ) -> some View { + WithFontScaledValue(blockSpacing) { blockSpacing in + base.preference(key: StructuredText.BlockSpacingKey.self, value: blockSpacing) + } + } + + /// Sets line spacing using a font-relative value. + @MainActor public func lineSpacing(_ lineSpacing: FontScaled) -> some View { + WithFontScaledValue(lineSpacing) { + base.lineSpacing($0) + } + } + + /// Adds padding using font-relative insets. + @MainActor public func padding(_ insets: FontScaled) -> some View { + WithFontScaledValue(insets) { + base.padding($0) + } + } + + /// Adds padding using a font-relative length. + @MainActor public func padding(_ length: FontScaled) -> some View { + WithFontScaledValue(length) { + base.padding($0) + } + } + + /// Adds padding on the specified edges using a font-relative length. + @MainActor public func padding(_ edges: Edge.Set, _ length: FontScaled) -> some View { + WithFontScaledValue(length) { + base.padding(edges, $0) + } + } + + /// Sets the view’s frame using font-relative dimensions. + @MainActor public func frame( + width: FontScaled? = nil, + height: FontScaled? = nil, + alignment: Alignment = .center + ) -> some View { + WithFontScaledValue( + FontScaled( + ProposedViewSize( + width: width?.value, + height: height?.value + ) + ) + ) { size in + base.frame(width: size.width, height: size.height, alignment: alignment) + } + } + + /// Sets the view’s minimum width using a font-relative value. + @MainActor public func frame( + minWidth: FontScaled, + alignment: Alignment = .center + ) -> some View { + WithFontScaledValue(minWidth) { + base.frame(minWidth: $0, alignment: alignment) + } + } + + /// Adds a background that can align with table cell bounds. + /// + /// Use this modifier when building custom table styles. It reads table cell bounds from + /// preferences and provides a ``StructuredText/TableLayout`` for precise alignment. + public func tableBackground( + @ViewBuilder content: @escaping (_ layout: StructuredText.TableLayout) -> some View + ) -> some View { + base.backgroundPreferenceValue(StructuredText.TableCell.BoundsKey.self) { values in + GeometryReader { geometry in + content(.init(values, geometry: geometry)) + } + .allowsHitTesting(false) + } + } + + /// Adds an overlay that can align with table cell bounds. + /// + /// Use this modifier when building custom table styles. It reads table cell bounds from + /// preferences and provides a ``StructuredText/TableLayout`` for precise alignment. + public func tableOverlay( + @ViewBuilder content: @escaping (_ layout: StructuredText.TableLayout) -> some View + ) -> some View { + base.overlayPreferenceValue(StructuredText.TableCell.BoundsKey.self) { values in + GeometryReader { geometry in + content(.init(values, geometry: geometry)) + } + .allowsHitTesting(false) + } + } + + /// Sets the spacing used between list items in ``StructuredText``. + public func listItemSpacing( + _ listItemSpacing: FontScaled + ) -> some View { + base.environment(\.listItemSpacing, listItemSpacing) + } + + /// Sets the custom emoji properties. + public func emojiProperties(_ emojiProperties: EmojiProperties) -> some View { + base.environment(\.emojiProperties, emojiProperties) + } + + /// Sets the math rendering properties. + public func mathProperties(_ mathProperties: MathProperties) -> some View { + base.environment(\.mathProperties, mathProperties) + } + + /// Scales the font used within the view by a constant factor. + @MainActor public func fontScale(_ scale: CGFloat) -> some View { + base.modifier(FontScaleModifier(scale)) + } + + /// Sets the attachment loader used to resolve image attachments. + public func imageAttachmentLoader(_ loader: some AttachmentLoader) -> some View { + base.environment(\.imageAttachmentLoader, loader) + } + + /// Sets the attachment loader used to resolve custom emoji attachments. + public func emojiAttachmentLoader(_ loader: some AttachmentLoader) -> some View { + base.environment(\.emojiAttachmentLoader, loader) + } + + /// Enables or disables text selection for ``InlineText`` and ``StructuredText``. + @available(tvOS, unavailable) + @available(watchOS, unavailable) + @inlinable + public func textSelection(_ selectability: some TextSelectability) -> some View { + #if TEXTUAL_ENABLE_TEXT_SELECTION + base.environment(\.textSelection, type(of: selectability)) + #else + base + #endif + } + + /// Sets the spacing used between table cells in ``StructuredText``. + public func tableCellSpacing( + horizontal: CGFloat? = nil, + vertical: CGFloat? = nil + ) -> some View { + base.preference( + key: StructuredText.TableCell.SpacingKey.self, + value: .init(horizontal: horizontal, vertical: vertical) + ) + } + + /// Controls how content that overflows horizontally behaves in ``Overflow``. + /// + /// Use ``OverflowMode/wrap`` to wrap content to the available width, or + /// ``OverflowMode/scroll`` to allow horizontal scrolling. + @inlinable + public func overflowMode(_ overflowMode: OverflowMode) -> some View { + base.environment(\.overflowMode, overflowMode) + } + + /// Sets the inline style used by ``InlineText`` and ``StructuredText``. + @inlinable + public func inlineStyle(_ style: InlineStyle) -> some View { + base.environment(\.inlineStyle, style) + } + + /// Sets the paragraph style used by ``StructuredText``. + @inlinable + public func paragraphStyle(_ paragraphStyle: some StructuredText.ParagraphStyle) -> some View { + base.environment(\.paragraphStyle, paragraphStyle) + } + + /// Sets the heading style used by ``StructuredText``. + @inlinable + public func headingStyle(_ headingStyle: some StructuredText.HeadingStyle) -> some View { + base.environment(\.headingStyle, headingStyle) + } + + /// Sets the block quote style used by ``StructuredText``. + @inlinable + public func blockQuoteStyle(_ blockQuoteStyle: some StructuredText.BlockQuoteStyle) -> some View { + base.environment(\.blockQuoteStyle, blockQuoteStyle) + } + + /// Sets the list item style used by ``StructuredText``. + @inlinable + public func listItemStyle( + _ listItemStyle: some StructuredText.ListItemStyle + ) -> some View { + base.environment(\.listItemStyle, listItemStyle) + } + + /// Sets the ordered list marker used by ``StructuredText``. + @inlinable + public func orderedListMarker( + _ orderedListMarker: some StructuredText.OrderedListMarker + ) -> some View { + base.environment(\.orderedListMarker, orderedListMarker) + } + + /// Sets the unordered list marker used by ``StructuredText``. + @inlinable + public func unorderedListMarker( + _ unorderedListMarker: some StructuredText.UnorderedListMarker + ) -> some View { + base.environment(\.unorderedListMarker, unorderedListMarker) + } + + /// Sets the thematic break style used by ``StructuredText``. + @inlinable + public func thematicBreakStyle( + _ thematicBreakStyle: some StructuredText.ThematicBreakStyle + ) -> some View { + base.environment(\.thematicBreakStyle, thematicBreakStyle) + } + + /// Sets the syntax highlighting theme used by code blocks in ``StructuredText``. + @inlinable + public func highlighterTheme(_ highlighterTheme: StructuredText.HighlighterTheme) -> some View { + base.environment(\.highlighterTheme, highlighterTheme) + } + + /// Sets the code block style used by ``StructuredText``. + @inlinable + public func codeBlockStyle( + _ codeBlockStyle: some StructuredText.CodeBlockStyle + ) -> some View { + base.environment(\.codeBlockStyle, codeBlockStyle) + } + + /// Sets the table cell style used by ``StructuredText``. + @inlinable + public func tableCellStyle( + _ tableCellStyle: some StructuredText.TableCellStyle + ) -> some View { + base.environment(\.tableCellStyle, tableCellStyle) + } + + /// Sets the table style used by ``StructuredText``. + @inlinable + public func tableStyle( + _ tableStyle: some StructuredText.TableStyle + ) -> some View { + base.environment(\.tableStyle, tableStyle) + } + + /// Sets all the styles used by ``StructuredText``. + /// + /// Use this modifier when you want to apply a consistent look to structured text in one place. + /// It sets the inline style, block styles, list markers, and code highlighting theme from a + /// single ``StructuredText/Style`` value. + @inlinable + public func structuredTextStyle(_ style: some StructuredText.Style) -> some View { + base + .environment(\.inlineStyle, style.inlineStyle) + .environment(\.headingStyle, style.headingStyle) + .environment(\.paragraphStyle, style.paragraphStyle) + .environment(\.blockQuoteStyle, style.blockQuoteStyle) + .environment(\.codeBlockStyle, style.codeBlockStyle) + .environment(\.listItemStyle, style.listItemStyle) + .environment(\.unorderedListMarker, style.unorderedListMarker) + .environment(\.orderedListMarker, style.orderedListMarker) + .environment(\.tableStyle, style.tableStyle) + .environment(\.tableCellStyle, style.tableCellStyle) + .environment(\.thematicBreakStyle, style.thematicBreakStyle) + } +} diff --git a/apps/maple-ios/Vendor/textual/UPSTREAM.json b/apps/maple-ios/Vendor/textual/UPSTREAM.json new file mode 100644 index 000000000..d41a51aee --- /dev/null +++ b/apps/maple-ios/Vendor/textual/UPSTREAM.json @@ -0,0 +1,971 @@ +{ + "repository": "https://github.com/gonzalezreal/textual.git", + "revision": "01b51875a5406eefc95f52a058cb059e7bc94dc4", + "files": [ + { + "path": "Sources/Textual/Attachment/Attachment.swift", + "upstream_sha256": "194bbb96b44f530c1f6d810e26188dc1a51c0faa486936b8336f220c8475022f", + "local_sha256": "194bbb96b44f530c1f6d810e26188dc1a51c0faa486936b8336f220c8475022f" + }, + { + "path": "Sources/Textual/Attachment/AttachmentLoader.swift", + "upstream_sha256": "b080e2dd3c88790dea7e90eb784fb7e069d2d81c9265d4c505ddfceb8755d7e8", + "local_sha256": "b080e2dd3c88790dea7e90eb784fb7e069d2d81c9265d4c505ddfceb8755d7e8" + }, + { + "path": "Sources/Textual/Attachment/ResourceAttachmentLoader.swift", + "upstream_sha256": "ffbe2496d93b8d630dbfa40415ebb03c5c0b9987e74469504cd8211e111dc686", + "local_sha256": "ffbe2496d93b8d630dbfa40415ebb03c5c0b9987e74469504cd8211e111dc686" + }, + { + "path": "Sources/Textual/Attachment/URLAttachmentLoader.swift", + "upstream_sha256": "0ad07de18bfc4167d155d96fecf9dcf0fc2be716e94de4305cee3bca82b43a14", + "local_sha256": "0ad07de18bfc4167d155d96fecf9dcf0fc2be716e94de4305cee3bca82b43a14" + }, + { + "path": "Sources/Textual/ColorEnvironmentValues.swift", + "upstream_sha256": "664907af71fd6c4db06334215b52ee9b9ed1a8571272639cb9b426badc9a17ab", + "local_sha256": "664907af71fd6c4db06334215b52ee9b9ed1a8571272639cb9b426badc9a17ab" + }, + { + "path": "Sources/Textual/Deprecations.swift", + "upstream_sha256": "5432e9b15ea537bd735cd7f1a2b025d6feaf23124f58b7841e4e5f02ce6d508e", + "local_sha256": "5432e9b15ea537bd735cd7f1a2b025d6feaf23124f58b7841e4e5f02ce6d508e" + }, + { + "path": "Sources/Textual/DynamicColor.swift", + "upstream_sha256": "957bf905ced0ebbb585de2c062a0a800f6fd94dd0ff5cef8eba317d2aba816c5", + "local_sha256": "957bf905ced0ebbb585de2c062a0a800f6fd94dd0ff5cef8eba317d2aba816c5" + }, + { + "path": "Sources/Textual/EmojiProperties.swift", + "upstream_sha256": "f01a974badab3dceedeae18de47e2506a803e8d41583fb924cf00d77f2a55ede", + "local_sha256": "f01a974badab3dceedeae18de47e2506a803e8d41583fb924cf00d77f2a55ede" + }, + { + "path": "Sources/Textual/FontScaled.swift", + "upstream_sha256": "6b36a2b63ea3d789b62b611f2170deda6e1cdec2ff5460c0ce236dc73c70cf2c", + "local_sha256": "6b36a2b63ea3d789b62b611f2170deda6e1cdec2ff5460c0ce236dc73c70cf2c" + }, + { + "path": "Sources/Textual/InlineText/InlineStyle+Default.swift", + "upstream_sha256": "00c82086f15281ec01c5ccf4da888ccde4bbd967ea6e90a3a7c0997b20501d93", + "local_sha256": "00c82086f15281ec01c5ccf4da888ccde4bbd967ea6e90a3a7c0997b20501d93" + }, + { + "path": "Sources/Textual/InlineText/InlineStyle+GitHub.swift", + "upstream_sha256": "6e2db417677c0bc8e88e65f03049d7dafa42e6a5b0a8ce8e50ac72029d10272d", + "local_sha256": "6e2db417677c0bc8e88e65f03049d7dafa42e6a5b0a8ce8e50ac72029d10272d" + }, + { + "path": "Sources/Textual/InlineText/InlineStyle.swift", + "upstream_sha256": "f59e60491c16dc24152c20c09769c6b70a1084250587e24cb69bfabab899dd70", + "local_sha256": "f59e60491c16dc24152c20c09769c6b70a1084250587e24cb69bfabab899dd70" + }, + { + "path": "Sources/Textual/InlineText/InlineText.swift", + "upstream_sha256": "4fcb9ab06dfc5477ab51158a7117a74d0c7de3b85e6a73f9aa39c59861605598", + "local_sha256": "4fcb9ab06dfc5477ab51158a7117a74d0c7de3b85e6a73f9aa39c59861605598" + }, + { + "path": "Sources/Textual/Internal/Attachment/AttachmentAttribute.swift", + "upstream_sha256": "230c2da09b4e87335968ec6f23afd4a9bd812a9c309a87510c5a7d4585f58fba", + "local_sha256": "230c2da09b4e87335968ec6f23afd4a9bd812a9c309a87510c5a7d4585f58fba" + }, + { + "path": "Sources/Textual/Internal/Attachment/AttachmentOverlay.swift", + "upstream_sha256": "070ce9009e08f2da3e16a40b440878326873c9443e1be043cc097d79d72f3359", + "local_sha256": "070ce9009e08f2da3e16a40b440878326873c9443e1be043cc097d79d72f3359" + }, + { + "path": "Sources/Textual/Internal/Attachment/AttachmentView.swift", + "upstream_sha256": "3227b5c219cd7cc3eca80c959cefab02871ed88f7e062a3e375ea861e742f36c", + "local_sha256": "3227b5c219cd7cc3eca80c959cefab02871ed88f7e062a3e375ea861e742f36c" + }, + { + "path": "Sources/Textual/Internal/Attachment/EmojiAttachment.swift", + "upstream_sha256": "fee5696a7201e80266320a70127c975a937cc2a540353107eb0efc5ecbb5def0", + "local_sha256": "fee5696a7201e80266320a70127c975a937cc2a540353107eb0efc5ecbb5def0" + }, + { + "path": "Sources/Textual/Internal/Attachment/EmojiResourceAttachment.swift", + "upstream_sha256": "f5550702de4ebf52626a09e2f20c88f7de57d49144d13a856b05e8b66220ec9a", + "local_sha256": "f5550702de4ebf52626a09e2f20c88f7de57d49144d13a856b05e8b66220ec9a" + }, + { + "path": "Sources/Textual/Internal/Attachment/ImageAttachment.swift", + "upstream_sha256": "532b9225d217de63aaf2382b3d3c65c98b13752547a742cefb017924ffee772b", + "local_sha256": "532b9225d217de63aaf2382b3d3c65c98b13752547a742cefb017924ffee772b" + }, + { + "path": "Sources/Textual/Internal/Attachment/ImageResourceAttachment.swift", + "upstream_sha256": "4895c0ef2768aee2185ccf83bcda914bb90f7aa0f02033dc2ce5f71de34ef0b0", + "local_sha256": "4895c0ef2768aee2185ccf83bcda914bb90f7aa0f02033dc2ce5f71de34ef0b0" + }, + { + "path": "Sources/Textual/Internal/Attachment/MathAttachment.swift", + "upstream_sha256": "e1ca7f06ea433c13aca611a678e184d5868e8377c9efe2c335bbdc5a8eeb6fe2", + "local_sha256": "e1ca7f06ea433c13aca611a678e184d5868e8377c9efe2c335bbdc5a8eeb6fe2" + }, + { + "path": "Sources/Textual/Internal/Attachment/WithAttachments.swift", + "upstream_sha256": "37fe05776b16451763e157f9ef0ff2aa43b036b44e0924e7e6b80f934734ea52", + "local_sha256": "37fe05776b16451763e157f9ef0ff2aa43b036b44e0924e7e6b80f934734ea52" + }, + { + "path": "Sources/Textual/Internal/Font/AnyFontProvider+Font.swift", + "upstream_sha256": "45da19a1b5a783aa1772432b3974f70833358e40dce28b692ec113f6b8b28c07", + "local_sha256": "45da19a1b5a783aa1772432b3974f70833358e40dce28b692ec113f6b8b28c07" + }, + { + "path": "Sources/Textual/Internal/Font/AnyFontProvider.swift", + "upstream_sha256": "5241b7492346d42b216232a6b8b0f9ee5204e0af12708a88d96fd8bcab8d6e71", + "local_sha256": "5241b7492346d42b216232a6b8b0f9ee5204e0af12708a88d96fd8bcab8d6e71" + }, + { + "path": "Sources/Textual/Internal/Font/FontModifier.swift", + "upstream_sha256": "a7abfc284d4d6c1b3944881602506c4c710af37173eaedb0fe3d1c4f5a096ca0", + "local_sha256": "a7abfc284d4d6c1b3944881602506c4c710af37173eaedb0fe3d1c4f5a096ca0" + }, + { + "path": "Sources/Textual/Internal/Font/FontModifierProvider.swift", + "upstream_sha256": "5904148b027576d96e5efa98d4a2fec0aa40539b146cc07062d380f68f9969eb", + "local_sha256": "5904148b027576d96e5efa98d4a2fec0aa40539b146cc07062d380f68f9969eb" + }, + { + "path": "Sources/Textual/Internal/Font/FontProvider.swift", + "upstream_sha256": "749d87bdc7a4552d50b808a6c54d44ebf790536ac06818b1d65e46ccb8e9a33f", + "local_sha256": "749d87bdc7a4552d50b808a6c54d44ebf790536ac06818b1d65e46ccb8e9a33f" + }, + { + "path": "Sources/Textual/Internal/Font/FontScaleModifier.swift", + "upstream_sha256": "4bcce30e0b6cc2a791c30024e038007f4304848c51058e00f6f98edec0da46e5", + "local_sha256": "4bcce30e0b6cc2a791c30024e038007f4304848c51058e00f6f98edec0da46e5" + }, + { + "path": "Sources/Textual/Internal/Font/NamedFontProvider.swift", + "upstream_sha256": "0f30ee6d73afdbb3dd6b53bdb0052a7193e4a818ec7622613969604fea7e14d4", + "local_sha256": "0f30ee6d73afdbb3dd6b53bdb0052a7193e4a818ec7622613969604fea7e14d4" + }, + { + "path": "Sources/Textual/Internal/Font/PlatformFont.swift", + "upstream_sha256": "10d11638f072b26b9e623f2fd9f69d1c7731de8806eb771ee9a02a443167aa94", + "local_sha256": "10d11638f072b26b9e623f2fd9f69d1c7731de8806eb771ee9a02a443167aa94" + }, + { + "path": "Sources/Textual/Internal/Font/PlatformFontProvider.swift", + "upstream_sha256": "154e3782e32fb61ecf19659e87f4d694e8ca19779044dd4a44e6365a8e42859d", + "local_sha256": "154e3782e32fb61ecf19659e87f4d694e8ca19779044dd4a44e6365a8e42859d" + }, + { + "path": "Sources/Textual/Internal/Font/StaticFontModifierProvider.swift", + "upstream_sha256": "ade427ec0a6200013e3dd5458776b077db72fc9fb154962a92de70132ff26548", + "local_sha256": "ade427ec0a6200013e3dd5458776b077db72fc9fb154962a92de70132ff26548" + }, + { + "path": "Sources/Textual/Internal/Font/SystemFontProvider.swift", + "upstream_sha256": "754cb11edfe5c428d674c8b1ceed14f8356a36d2256f0c06eabfde6df558fdb6", + "local_sha256": "754cb11edfe5c428d674c8b1ceed14f8356a36d2256f0c06eabfde6df558fdb6" + }, + { + "path": "Sources/Textual/Internal/Font/TextStyleFontProvider.swift", + "upstream_sha256": "774c4bfd9d1aac86d4ee2ea48f370d39473d67e3e0dea332c5e8342e00994587", + "local_sha256": "774c4bfd9d1aac86d4ee2ea48f370d39473d67e3e0dea332c5e8342e00994587" + }, + { + "path": "Sources/Textual/Internal/Font/WithFontScaledValue.swift", + "upstream_sha256": "0ac4d6e7af1d6586c7959847f60ce79657e626234db50909e94ce7d994b60f07", + "local_sha256": "0ac4d6e7af1d6586c7959847f60ce79657e626234db50909e94ce7d994b60f07" + }, + { + "path": "Sources/Textual/Internal/Formatting/Formatter+HTML.swift", + "upstream_sha256": "450f36b58b128b3d98eab881fc500c101785034b2e0ce0726ccb2bdea615ed36", + "local_sha256": "6ee4b93a0d7edc6c3dabdf2dcec4d405128e4f056c7a8014f9493895e5f1f758" + }, + { + "path": "Sources/Textual/Internal/Formatting/Formatter+PlainText.swift", + "upstream_sha256": "260ef1eb7fd03e09902189343e67c1f7ff9e0936639494443093517ac36e154f", + "local_sha256": "a31a393ac6bad145b3561b1aef9ee896e782225f8a986642bd61e281286c6419" + }, + { + "path": "Sources/Textual/Internal/Formatting/Formatter.swift", + "upstream_sha256": "e961ea1950ffc0b72bd68ceb8e6005e09008efdc31b1a91fb325e5bab967c8db", + "local_sha256": "e961ea1950ffc0b72bd68ceb8e6005e09008efdc31b1a91fb325e5bab967c8db" + }, + { + "path": "Sources/Textual/Internal/Formatting/TransferableText.swift", + "upstream_sha256": "f9ae0bfd86e687966b13303900ea379e8e2e5af52de2ea3f349dc8b832ab7d29", + "local_sha256": "f9ae0bfd86e687966b13303900ea379e8e2e5af52de2ea3f349dc8b832ab7d29" + }, + { + "path": "Sources/Textual/Internal/Helpers/Array.swift", + "upstream_sha256": "c24b13c87db5265ae6b5a4fac814eeb4fb9bea07220480897a771ca6f306f3f8", + "local_sha256": "c24b13c87db5265ae6b5a4fac814eeb4fb9bea07220480897a771ca6f306f3f8" + }, + { + "path": "Sources/Textual/Internal/Helpers/AttributedString.swift", + "upstream_sha256": "b5de6d87181ce6335545c06abf914a558e1c3dc6fb10b1a0d568081740e327e7", + "local_sha256": "b5de6d87181ce6335545c06abf914a558e1c3dc6fb10b1a0d568081740e327e7" + }, + { + "path": "Sources/Textual/Internal/Helpers/Box.swift", + "upstream_sha256": "e8f600756e01403fbaf59e42d2732ff55c74cf6f6a4c172df5b78aecbaad8535", + "local_sha256": "e8f600756e01403fbaf59e42d2732ff55c74cf6f6a4c172df5b78aecbaad8535" + }, + { + "path": "Sources/Textual/Internal/Helpers/Bundle.swift", + "upstream_sha256": "72c66333cb1456519d659d5e1db007ce9e6dfc75f8b3efe4c77247b6a0e06847", + "local_sha256": "72c66333cb1456519d659d5e1db007ce9e6dfc75f8b3efe4c77247b6a0e06847" + }, + { + "path": "Sources/Textual/Internal/Helpers/CGImage+PNGData.swift", + "upstream_sha256": "40cd24b109604d339d3fefd5f24aa08f80875875319ef7b27a706e839a054562", + "local_sha256": "40cd24b109604d339d3fefd5f24aa08f80875875319ef7b27a706e839a054562" + }, + { + "path": "Sources/Textual/Internal/Helpers/Logger.swift", + "upstream_sha256": "e98419a3566e5464158182d2adac1b0904b5b9c30788c2d3fd1529f9a5a7ea9f", + "local_sha256": "e98419a3566e5464158182d2adac1b0904b5b9c30788c2d3fd1529f9a5a7ea9f" + }, + { + "path": "Sources/Textual/Internal/Helpers/PlatformImage.swift", + "upstream_sha256": "65089a11c9b4da8c7bcc9564f55605f59f89ec4ac4543330ed25f1d2e17a0a76", + "local_sha256": "65089a11c9b4da8c7bcc9564f55605f59f89ec4ac4543330ed25f1d2e17a0a76" + }, + { + "path": "Sources/Textual/Internal/Helpers/Range.swift", + "upstream_sha256": "3df401190d63e6fd9fe1ce14d0bd7f2c759c9d2d651373533432b5577f56a6ed", + "local_sha256": "3df401190d63e6fd9fe1ce14d0bd7f2c759c9d2d651373533432b5577f56a6ed" + }, + { + "path": "Sources/Textual/Internal/Helpers/Tuple.swift", + "upstream_sha256": "b4fa6bafd57db837c61023388faeae2c8e6a2b536b65fa7fa6ced202547cf536", + "local_sha256": "b4fa6bafd57db837c61023388faeae2c8e6a2b536b65fa7fa6ced202547cf536" + }, + { + "path": "Sources/Textual/Internal/Helpers/VerticalAlignment+FirstTextCenter.swift", + "upstream_sha256": "3804605ee3a429c379f8d1c41958e6f7c9a2ab6bb54000c5fec92edcc1c478b1", + "local_sha256": "3804605ee3a429c379f8d1c41958e6f7c9a2ab6bb54000c5fec92edcc1c478b1" + }, + { + "path": "Sources/Textual/Internal/Highlighter/CodeTokenizer.swift", + "upstream_sha256": "43e3bfd472ca2cd8b14f71c5ef65829528ce55b9857c499b38d8aaa269084e9e", + "local_sha256": "43e3bfd472ca2cd8b14f71c5ef65829528ce55b9857c499b38d8aaa269084e9e" + }, + { + "path": "Sources/Textual/Internal/Highlighter/HighlightedTextFragment.swift", + "upstream_sha256": "0b586c394905d1de8b0e7149506df915d6cd3da038abaae16a2b8777d7fcfdd9", + "local_sha256": "0b586c394905d1de8b0e7149506df915d6cd3da038abaae16a2b8777d7fcfdd9" + }, + { + "path": "Sources/Textual/Internal/Highlighter/Prism/prism-bundle.js", + "upstream_sha256": "22b70192dc9d73377b5b8fe2c85655c7e3218afd37b65db638b4e168c4bacbf5", + "local_sha256": "22b70192dc9d73377b5b8fe2c85655c7e3218afd37b65db638b4e168c4bacbf5" + }, + { + "path": "Sources/Textual/Internal/Image/Image.swift", + "upstream_sha256": "faed7ccd5c534072beead46c498c000023e801ce40be74510a367810599fc756", + "local_sha256": "faed7ccd5c534072beead46c498c000023e801ce40be74510a367810599fc756" + }, + { + "path": "Sources/Textual/Internal/Image/ImageLoader.swift", + "upstream_sha256": "113383f59f40cc1e06a15dc3b80fcb8a94054c959cd97995c794983dce1cf964", + "local_sha256": "113383f59f40cc1e06a15dc3b80fcb8a94054c959cd97995c794983dce1cf964" + }, + { + "path": "Sources/Textual/Internal/Image/ImageView.swift", + "upstream_sha256": "3061595d941f7d01e3b80eea92ea66f0d972c3d9d2587cbd454dde9e942c7086", + "local_sha256": "3061595d941f7d01e3b80eea92ea66f0d972c3d9d2587cbd454dde9e942c7086" + }, + { + "path": "Sources/Textual/Internal/InlineText/WithInlineStyle.swift", + "upstream_sha256": "838c1831eda252291266ddc4542ddb1876385c132d45b5fa61bb29e93043e255", + "local_sha256": "838c1831eda252291266ddc4542ddb1876385c132d45b5fa61bb29e93043e255" + }, + { + "path": "Sources/Textual/Internal/MarkdownParser/Emoji+Preview.swift", + "upstream_sha256": "bf9ec77220552cfb031e4e00528fffbabad525eb65f05f0fe2914cfe46e6ca84", + "local_sha256": "bf9ec77220552cfb031e4e00528fffbabad525eb65f05f0fe2914cfe46e6ca84" + }, + { + "path": "Sources/Textual/Internal/MarkdownParser/PatternProcessor.swift", + "upstream_sha256": "48148710614283bf908745bb937cf604b15ea404442a045dea83e4f6097a3a49", + "local_sha256": "48148710614283bf908745bb937cf604b15ea404442a045dea83e4f6097a3a49" + }, + { + "path": "Sources/Textual/Internal/MarkdownParser/PatternTokenizer.swift", + "upstream_sha256": "ca3e811a6fbd2723a17714ce03ad7d56df52d7f213a1188e5220f6d80f772c30", + "local_sha256": "ca3e811a6fbd2723a17714ce03ad7d56df52d7f213a1188e5220f6d80f772c30" + }, + { + "path": "Sources/Textual/Internal/StructuredText/BlockContent.swift", + "upstream_sha256": "5dba454c122e17091dfa89797f2554b6b13082f5d3a72209adfcba2e36820c84", + "local_sha256": "5dba454c122e17091dfa89797f2554b6b13082f5d3a72209adfcba2e36820c84" + }, + { + "path": "Sources/Textual/Internal/StructuredText/BlockQuote.swift", + "upstream_sha256": "a90871e3a65d6448e2e7ade50d71c92057311ccf81d8d4fa0a3e6f9bcb75259b", + "local_sha256": "a90871e3a65d6448e2e7ade50d71c92057311ccf81d8d4fa0a3e6f9bcb75259b" + }, + { + "path": "Sources/Textual/Internal/StructuredText/BlockSpacingKey.swift", + "upstream_sha256": "7fe5fb1f17da8dec9123bd3c4e52ae2613e9fdb4f757d2b77d848123c64b53a3", + "local_sha256": "7fe5fb1f17da8dec9123bd3c4e52ae2613e9fdb4f757d2b77d848123c64b53a3" + }, + { + "path": "Sources/Textual/Internal/StructuredText/BlockVStack.swift", + "upstream_sha256": "3c85ba6073a6b0ec57c1d468cce0d37fa0a3cc5f53217bb436e2d32bda6e1daf", + "local_sha256": "3c85ba6073a6b0ec57c1d468cce0d37fa0a3cc5f53217bb436e2d32bda6e1daf" + }, + { + "path": "Sources/Textual/Internal/StructuredText/CodeBlock.swift", + "upstream_sha256": "db2a780de3bb55e007c60b2f7e7de62b291e30b6bb8e3df83cc43530ea617608", + "local_sha256": "db2a780de3bb55e007c60b2f7e7de62b291e30b6bb8e3df83cc43530ea617608" + }, + { + "path": "Sources/Textual/Internal/StructuredText/Heading.swift", + "upstream_sha256": "7b7626253700f565b7e11632ddfba74bd875d205abb1c1c7d9c1bb789a6c5ef0", + "local_sha256": "7b7626253700f565b7e11632ddfba74bd875d205abb1c1c7d9c1bb789a6c5ef0" + }, + { + "path": "Sources/Textual/Internal/StructuredText/ListEnvironment.swift", + "upstream_sha256": "4ecd77e707be108c6c9771894bd34d517605b704d7bc8f7a97406a2387bcf5ba", + "local_sha256": "4ecd77e707be108c6c9771894bd34d517605b704d7bc8f7a97406a2387bcf5ba" + }, + { + "path": "Sources/Textual/Internal/StructuredText/MathBlock.swift", + "upstream_sha256": "001aecf51cea87c342440a1f7c12dd83c30c30687fccf46b8781d50c4ce9548b", + "local_sha256": "001aecf51cea87c342440a1f7c12dd83c30c30687fccf46b8781d50c4ce9548b" + }, + { + "path": "Sources/Textual/Internal/StructuredText/MathCodeBlock.swift", + "upstream_sha256": "8ae1012b306b36d308adabbf513f364bdaedfc3919473b50ed51ed0b5f7b1ee5", + "local_sha256": "8ae1012b306b36d308adabbf513f364bdaedfc3919473b50ed51ed0b5f7b1ee5" + }, + { + "path": "Sources/Textual/Internal/StructuredText/OrderedList.swift", + "upstream_sha256": "d96b79cbc06a1a1d8fb9feec202ae85916e78c9b8b8e789c2ec4896e4943a2de", + "local_sha256": "d96b79cbc06a1a1d8fb9feec202ae85916e78c9b8b8e789c2ec4896e4943a2de" + }, + { + "path": "Sources/Textual/Internal/StructuredText/Paragraph.swift", + "upstream_sha256": "8b9d98466a95adc176e98e137a8195cb7a50e653dd1112a7d3dea06b9f412947", + "local_sha256": "8b9d98466a95adc176e98e137a8195cb7a50e653dd1112a7d3dea06b9f412947" + }, + { + "path": "Sources/Textual/Internal/StructuredText/Style/OverflowFrameKey.swift", + "upstream_sha256": "d2b2e948e6ab89235ea45a2bbf390f5baa19955b512e0500167c932f1d24b8c6", + "local_sha256": "d2b2e948e6ab89235ea45a2bbf390f5baa19955b512e0500167c932f1d24b8c6" + }, + { + "path": "Sources/Textual/Internal/StructuredText/Style/ResolvedBlockQuoteStyle.swift", + "upstream_sha256": "ce5c5873bf391227d470ecc812f683170b5e8ff5d51ec5c0fa6d6c22818ba389", + "local_sha256": "ce5c5873bf391227d470ecc812f683170b5e8ff5d51ec5c0fa6d6c22818ba389" + }, + { + "path": "Sources/Textual/Internal/StructuredText/Style/ResolvedCodeBlockStyle.swift", + "upstream_sha256": "925fe99ae4a0763605da0469f73c668d2fbf5168b78a005c96e72d66ac274bf3", + "local_sha256": "925fe99ae4a0763605da0469f73c668d2fbf5168b78a005c96e72d66ac274bf3" + }, + { + "path": "Sources/Textual/Internal/StructuredText/Style/ResolvedHeadingStyle.swift", + "upstream_sha256": "c14894b099f9d6ad40c5aabcbfed92f1753db0324fbb74a123af324c79e87f33", + "local_sha256": "c14894b099f9d6ad40c5aabcbfed92f1753db0324fbb74a123af324c79e87f33" + }, + { + "path": "Sources/Textual/Internal/StructuredText/Style/ResolvedListItemStyle.swift", + "upstream_sha256": "abe7a913aedb934bd6b25ae878fa2a22c97725bcad119d39d0ec8e299224efef", + "local_sha256": "abe7a913aedb934bd6b25ae878fa2a22c97725bcad119d39d0ec8e299224efef" + }, + { + "path": "Sources/Textual/Internal/StructuredText/Style/ResolvedOrderedListMarker.swift", + "upstream_sha256": "3d90f120813e4cb94f4356cb54a10b6e1745aad19c36d4c2157e53a820a9e979", + "local_sha256": "3d90f120813e4cb94f4356cb54a10b6e1745aad19c36d4c2157e53a820a9e979" + }, + { + "path": "Sources/Textual/Internal/StructuredText/Style/ResolvedParagraphStyle.swift", + "upstream_sha256": "c7bbd7944ef0b2a9455d757c55d9ffccac770259307a3d2c650a076d4531b8e4", + "local_sha256": "c7bbd7944ef0b2a9455d757c55d9ffccac770259307a3d2c650a076d4531b8e4" + }, + { + "path": "Sources/Textual/Internal/StructuredText/Style/ResolvedTableCellStyle.swift", + "upstream_sha256": "ad7027af5438d859f66a2cb57f72632d89404871e4c20d0aa55580bb3495e11a", + "local_sha256": "ad7027af5438d859f66a2cb57f72632d89404871e4c20d0aa55580bb3495e11a" + }, + { + "path": "Sources/Textual/Internal/StructuredText/Style/ResolvedTableStyle.swift", + "upstream_sha256": "bb73bb48c2a45970559b1d0dea02d6b1722c0e5568e2bc49ffeffc883038e3ab", + "local_sha256": "bb73bb48c2a45970559b1d0dea02d6b1722c0e5568e2bc49ffeffc883038e3ab" + }, + { + "path": "Sources/Textual/Internal/StructuredText/Style/ResolvedThematicBreakStyle.swift", + "upstream_sha256": "c863bb4bf7d080b5c74ae06bc044291edb6d2c57fbf515c75731b4c439c11e67", + "local_sha256": "c863bb4bf7d080b5c74ae06bc044291edb6d2c57fbf515c75731b4c439c11e67" + }, + { + "path": "Sources/Textual/Internal/StructuredText/Style/ResolvedUnorderedListMarker.swift", + "upstream_sha256": "2ce8e477344f7e0f82888fcb11366cc62581147cd015773b6100ee95a54692f4", + "local_sha256": "2ce8e477344f7e0f82888fcb11366cc62581147cd015773b6100ee95a54692f4" + }, + { + "path": "Sources/Textual/Internal/StructuredText/Table.swift", + "upstream_sha256": "e5a4089d27e0f473a6409a54e3f4594cae0797cb3190457ab408593202b8c055", + "local_sha256": "e5a4089d27e0f473a6409a54e3f4594cae0797cb3190457ab408593202b8c055" + }, + { + "path": "Sources/Textual/Internal/StructuredText/TableCell.swift", + "upstream_sha256": "823afb0cfe7bb127422b88b56796a34284137b5421907ebd70be129d7ee0c60d", + "local_sha256": "823afb0cfe7bb127422b88b56796a34284137b5421907ebd70be129d7ee0c60d" + }, + { + "path": "Sources/Textual/Internal/StructuredText/ThematicBreak.swift", + "upstream_sha256": "14e0766c077764989326da8e230bdc912ee3c574e054d91fce0e24cef9364fb4", + "local_sha256": "14e0766c077764989326da8e230bdc912ee3c574e054d91fce0e24cef9364fb4" + }, + { + "path": "Sources/Textual/Internal/StructuredText/UnorderedList.swift", + "upstream_sha256": "95a9ab2479c555a0c5d84747ba1fd9ed0b8819d1cb93bb0b1607c14fccbec3cc", + "local_sha256": "95a9ab2479c555a0c5d84747ba1fd9ed0b8819d1cb93bb0b1607c14fccbec3cc" + }, + { + "path": "Sources/Textual/Internal/TextFragment/TextBuilder.swift", + "upstream_sha256": "8bc965e2ec5c1146ef9857114c24cb32826c9e0e836dc5c60a28157f1d3b027e", + "local_sha256": "8bc965e2ec5c1146ef9857114c24cb32826c9e0e836dc5c60a28157f1d3b027e" + }, + { + "path": "Sources/Textual/Internal/TextFragment/TextFragment.swift", + "upstream_sha256": "839bae276e3451107bf4d28f9f29a48ea09ea202a54c44a86e708243ec03a6b8", + "local_sha256": "839bae276e3451107bf4d28f9f29a48ea09ea202a54c44a86e708243ec03a6b8" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/AppKit/AppKitTextInteractionOverlay.swift", + "upstream_sha256": "02dff5b4f48e2d25a5487e3ae094a6acbe166b97a38217f1f94c3468d562c903", + "local_sha256": "02dff5b4f48e2d25a5487e3ae094a6acbe166b97a38217f1f94c3468d562c903" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/AppKit/AppKitTextSelectionInteraction.swift", + "upstream_sha256": "45d79bfd0746790abf59f806d062b5f69e53a16d50a70aad785139a30d006c05", + "local_sha256": "45d79bfd0746790abf59f806d062b5f69e53a16d50a70aad785139a30d006c05" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/AppKit/AppKitTextSelectionView.swift", + "upstream_sha256": "d2826dd3a57f3faf4cd258d33bd0314e4b90371c84c44b147376b4cee8f62531", + "local_sha256": "d2826dd3a57f3faf4cd258d33bd0314e4b90371c84c44b147376b4cee8f62531" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/AppKit/NSTextInteractionView.swift", + "upstream_sha256": "57ed95840c4467439e50d55e3fcd7634b751858700e400eb40e359029d79d4a1", + "local_sha256": "57ed95840c4467439e50d55e3fcd7634b751858700e400eb40e359029d79d4a1" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/IndexPath+TextPosition.swift", + "upstream_sha256": "a8bf8bb772d8f9ecfc4e29664d8dd2e26e47951f7f4e29d1735b16ecb10ce77b", + "local_sha256": "a8bf8bb772d8f9ecfc4e29664d8dd2e26e47951f7f4e29d1735b16ecb10ce77b" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/LayoutDirection+Locale.swift", + "upstream_sha256": "ea982d2e7a1c4651cc6770861a1816f6be198aca5bb2ddeea56bf9b3a1d60069", + "local_sha256": "ea982d2e7a1c4651cc6770861a1816f6be198aca5bb2ddeea56bf9b3a1d60069" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/LinkAttribute.swift", + "upstream_sha256": "adfca7dbea1069919a2fddc20ab3e86fd91c855ef5523206ebd0f7bd86d8efe2", + "local_sha256": "adfca7dbea1069919a2fddc20ab3e86fd91c855ef5523206ebd0f7bd86d8efe2" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextLayout/EmptyTextLayoutCollection.swift", + "upstream_sha256": "98a60b42ba7b5d5925693ea1c96e84cbf8aad1b0e83815d6f50637ccafdd14e7", + "local_sha256": "98a60b42ba7b5d5925693ea1c96e84cbf8aad1b0e83815d6f50637ccafdd14e7" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextLayout/Layout+Internals.swift", + "upstream_sha256": "f32d1efb74e6116801785c4a12219209f23171be8e3299f353afa56868f1f875", + "local_sha256": "f32d1efb74e6116801785c4a12219209f23171be8e3299f353afa56868f1f875" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextLayout/LiveTextLayoutCollection.swift", + "upstream_sha256": "d8d1b34f62a64c9ed12bfaa017e24d8661be65d04833f4ac947b53691dd5a38b", + "local_sha256": "d8d1b34f62a64c9ed12bfaa017e24d8661be65d04833f4ac947b53691dd5a38b" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+AttributedStringAccess.swift", + "upstream_sha256": "ecf59989161809e177c64987ce64e17400f769c560a4d6125dba6c538f85bf05", + "local_sha256": "ecf59989161809e177c64987ce64e17400f769c560a4d6125dba6c538f85bf05" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+Geometry.swift", + "upstream_sha256": "d751b97d8f149839ed68d0f1e7c2684845c46b40f5d4f5162e123be8f88da0bf", + "local_sha256": "d751b97d8f149839ed68d0f1e7c2684845c46b40f5d4f5162e123be8f88da0bf" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+Positioning.swift", + "upstream_sha256": "80f0c1d10a9eb597018e1a24dde203880d3f8ffdbe2628a21338e4c0e56576d3", + "local_sha256": "80f0c1d10a9eb597018e1a24dde203880d3f8ffdbe2628a21338e4c0e56576d3" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+Ranges.swift", + "upstream_sha256": "2859b1b13d1d2d91e84b45bbc74b18f4bb2d01db82fb69414b4090decbd803ed", + "local_sha256": "2859b1b13d1d2d91e84b45bbc74b18f4bb2d01db82fb69414b4090decbd803ed" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+RunSliceTraversal.swift", + "upstream_sha256": "ce19158ba12786f5acc45b10456e8f7b52a0e444c306cc5d82f041aa85880f46", + "local_sha256": "ce19158ba12786f5acc45b10456e8f7b52a0e444c306cc5d82f041aa85880f46" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+SelectionRects.swift", + "upstream_sha256": "e9b491641be35a3697d948a62f3917ed5343fae44cc5250c695db238c12436c1", + "local_sha256": "e9b491641be35a3697d948a62f3917ed5343fae44cc5250c695db238c12436c1" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection.swift", + "upstream_sha256": "f0e150fb0a6c6be19c894aeeab3ab6d5737ba59e939b8b7146815fb19229fc4a", + "local_sha256": "f0e150fb0a6c6be19c894aeeab3ab6d5737ba59e939b8b7146815fb19229fc4a" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextLayout/View+TextLayoutCollection.swift", + "upstream_sha256": "6a41b1b861c71ed9529274b4ced3279b831b12ebc35cbbba539a5056b37589e9", + "local_sha256": "6a41b1b861c71ed9529274b4ced3279b831b12ebc35cbbba539a5056b37589e9" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextLinkInteraction.swift", + "upstream_sha256": "69b03439a898385094cd7c332cc2b0e709d5dec94e6cec610c2903bb918669cf", + "local_sha256": "69b03439a898385094cd7c332cc2b0e709d5dec94e6cec610c2903bb918669cf" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextPosition.swift", + "upstream_sha256": "be2d6c351dadccaee57c7ffeae4f8806ba3d8bc52cfd8511825a27e79d5b4df0", + "local_sha256": "be2d6c351dadccaee57c7ffeae4f8806ba3d8bc52cfd8511825a27e79d5b4df0" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextRange.swift", + "upstream_sha256": "1e120b1709260b3ff17b40076973dde635660b27b85bc529114d3bfdbb49a6a2", + "local_sha256": "1e120b1709260b3ff17b40076973dde635660b27b85bc529114d3bfdbb49a6a2" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextSelectionBackground.swift", + "upstream_sha256": "d4f014ad759f16f937667ebf3caf8f8f7cac290b057d7f0d3ead1c26eedc9f26", + "local_sha256": "d4f014ad759f16f937667ebf3caf8f8f7cac290b057d7f0d3ead1c26eedc9f26" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextSelectionCoordinator.swift", + "upstream_sha256": "1c3f8393295cc67c0f3a1aa617b9045703e2205a5332fb3f9dfbedd3e16020eb", + "local_sha256": "1c3f8393295cc67c0f3a1aa617b9045703e2205a5332fb3f9dfbedd3e16020eb" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextSelectionInteraction.swift", + "upstream_sha256": "cb31920a50fbcfb23c3874bfbc1629587f86ae66f3c1f35947e058a49f4338a4", + "local_sha256": "cb31920a50fbcfb23c3874bfbc1629587f86ae66f3c1f35947e058a49f4338a4" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextSelectionModel.swift", + "upstream_sha256": "2e95d6f1cf6af7dd9cc79a9742a8720e59e3baa5cac0cf98713c0b8c35c86b37", + "local_sha256": "2e95d6f1cf6af7dd9cc79a9742a8720e59e3baa5cac0cf98713c0b8c35c86b37" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/Shared/TextSelectionRect.swift", + "upstream_sha256": "29a2b2c3de16e56dcd4a255c9dda2d3c16e6af331b7be7d4552ccd45db68d04f", + "local_sha256": "29a2b2c3de16e56dcd4a255c9dda2d3c16e6af331b7be7d4552ccd45db68d04f" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/UIKit/TextActivityItemSource.swift", + "upstream_sha256": "0a7ba7f5ced58d87decb1bd1d623ec4ab6cf6745f1b8b87dff45f7ee33295a29", + "local_sha256": "0a7ba7f5ced58d87decb1bd1d623ec4ab6cf6745f1b8b87dff45f7ee33295a29" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/UIKit/TextPositionBox.swift", + "upstream_sha256": "758f17ab92714617e153d1275957e92bf2bac795bdb1e40023a1e42cfc059b1e", + "local_sha256": "758f17ab92714617e153d1275957e92bf2bac795bdb1e40023a1e42cfc059b1e" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/UIKit/TextRangeBox.swift", + "upstream_sha256": "83612fee5c0eddb89fed82a0ec6c99e00ba9101902c1a74bbd4d1330b82de8ed", + "local_sha256": "83612fee5c0eddb89fed82a0ec6c99e00ba9101902c1a74bbd4d1330b82de8ed" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/UIKit/TextSelectionRectBox.swift", + "upstream_sha256": "699d4e9e3f5fdca9999e8cf3bb1f077b3a81646e0fc9e276547fe3bb965e7612", + "local_sha256": "699d4e9e3f5fdca9999e8cf3bb1f077b3a81646e0fc9e276547fe3bb965e7612" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/UIKit/UIKitTextInteractionOverlay.swift", + "upstream_sha256": "5b169e9589b6f78d851c4be2870ad674de446ab1d5fcebd365f4efb4b03ac45f", + "local_sha256": "5b169e9589b6f78d851c4be2870ad674de446ab1d5fcebd365f4efb4b03ac45f" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/UIKit/UIKitTextSelectionInteraction.swift", + "upstream_sha256": "db2ac2603df19f70bc170fcb1706d76c7f47563a8045eefaadf035e9af6c2b05", + "local_sha256": "db2ac2603df19f70bc170fcb1706d76c7f47563a8045eefaadf035e9af6c2b05" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/UIKit/UITextInteractionView+UITextInput.swift", + "upstream_sha256": "ae297d82eda5becc0a64a1efcfbd99702e7c3163d06486b178ac9b5c6532945e", + "local_sha256": "ae297d82eda5becc0a64a1efcfbd99702e7c3163d06486b178ac9b5c6532945e" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/UIKit/UITextInteractionView+UITextInputTokenizer.swift", + "upstream_sha256": "888826d312cc25c42a8b85a2491c64d6f429887972556162fb07c397dcaa071c", + "local_sha256": "888826d312cc25c42a8b85a2491c64d6f429887972556162fb07c397dcaa071c" + }, + { + "path": "Sources/Textual/Internal/TextInteraction/UIKit/UITextInteractionView.swift", + "upstream_sha256": "7ec6c8f8f6ec8aa0b7cb444c2e3bd4083364a15c9da6ddbc0e7eb289535cc20e", + "local_sha256": "ff74afe5771e3a867af79663b15b52baf3262c4b9cc9a03df243829253f9c2f0" + }, + { + "path": "Sources/Textual/MarkdownParser/AttributedStringMarkdownParser.swift", + "upstream_sha256": "d470a3d8c59f0d321eddeba9e9c005dc4cb861c9e271d5c8555243a1abd77c29", + "local_sha256": "d470a3d8c59f0d321eddeba9e9c005dc4cb861c9e271d5c8555243a1abd77c29" + }, + { + "path": "Sources/Textual/MarkdownParser/Emoji.swift", + "upstream_sha256": "618a38612e907ca0615be4e397f081623c4ddb330ba3342ca51f728afbf02549", + "local_sha256": "618a38612e907ca0615be4e397f081623c4ddb330ba3342ca51f728afbf02549" + }, + { + "path": "Sources/Textual/MarkdownParser/SyntaxExtension.swift", + "upstream_sha256": "7056bf85712c1801fa0606de4bc842634f645055b65d09d52345570578a99a6a", + "local_sha256": "7056bf85712c1801fa0606de4bc842634f645055b65d09d52345570578a99a6a" + }, + { + "path": "Sources/Textual/MarkdownParser/TextualAttributes.swift", + "upstream_sha256": "9e13f5b291aa800135c81f75ad7989b2fb947fa3064c40475f412582e5fbde09", + "local_sha256": "9e13f5b291aa800135c81f75ad7989b2fb947fa3064c40475f412582e5fbde09" + }, + { + "path": "Sources/Textual/MarkupParser.swift", + "upstream_sha256": "6c3d6b880513cfa86b842588a286a8dadeb9a79c063298421f5c37eed6e5de20", + "local_sha256": "6c3d6b880513cfa86b842588a286a8dadeb9a79c063298421f5c37eed6e5de20" + }, + { + "path": "Sources/Textual/MathProperties.swift", + "upstream_sha256": "0c07b9647838963d3148981d704c7fed3d2caac455d34a8fd37aa4ea0cf581cf", + "local_sha256": "0c07b9647838963d3148981d704c7fed3d2caac455d34a8fd37aa4ea0cf581cf" + }, + { + "path": "Sources/Textual/StructuredText/HighlighterTheme/HighlighterTheme+Default.swift", + "upstream_sha256": "00d5cc56ced06c3efd7fee4091d85dc5e3852788425b9a6f874e9f470b3c2a9c", + "local_sha256": "00d5cc56ced06c3efd7fee4091d85dc5e3852788425b9a6f874e9f470b3c2a9c" + }, + { + "path": "Sources/Textual/StructuredText/HighlighterTheme/HighlighterTheme+Plain.swift", + "upstream_sha256": "e68930f38227780c54551a92657f950a4fccf6e2f33e4babc56339e3c61e54b7", + "local_sha256": "e68930f38227780c54551a92657f950a4fccf6e2f33e4babc56339e3c61e54b7" + }, + { + "path": "Sources/Textual/StructuredText/HighlighterTheme/HighlighterTheme.swift", + "upstream_sha256": "276b3d897611aebce37f9b06373adbe5bde1d66ec99e2f3f7da30bb0cc8c6197", + "local_sha256": "276b3d897611aebce37f9b06373adbe5bde1d66ec99e2f3f7da30bb0cc8c6197" + }, + { + "path": "Sources/Textual/StructuredText/HighlighterTheme/TokenType.swift", + "upstream_sha256": "73d933cf60a7d7cafe4d34ac41e7c81331a82ed883b0cf6236999bbd9a230b9e", + "local_sha256": "73d933cf60a7d7cafe4d34ac41e7c81331a82ed883b0cf6236999bbd9a230b9e" + }, + { + "path": "Sources/Textual/StructuredText/StructuredText.swift", + "upstream_sha256": "72bd3ad4db3ffae696bc95773735de03c537d2467c79c89f4ba0e644783fc2dd", + "local_sha256": "72bd3ad4db3ffae696bc95773735de03c537d2467c79c89f4ba0e644783fc2dd" + }, + { + "path": "Sources/Textual/StructuredText/Style/BlockQuoteStyle.swift", + "upstream_sha256": "b00409732e18b5ceb2f9a349c8235d83ee1ab2f3c2554dd9e44ba9f28da73930", + "local_sha256": "b00409732e18b5ceb2f9a349c8235d83ee1ab2f3c2554dd9e44ba9f28da73930" + }, + { + "path": "Sources/Textual/StructuredText/Style/BlockSpacing.swift", + "upstream_sha256": "a8ea9a161bf19dfa9a50f746914b79e5df50cd8f58061361e6330017f21d1894", + "local_sha256": "a8ea9a161bf19dfa9a50f746914b79e5df50cd8f58061361e6330017f21d1894" + }, + { + "path": "Sources/Textual/StructuredText/Style/BlockStyleConfiguration.swift", + "upstream_sha256": "6bd90d22c28dc4abe2335a07291bea6c8fad8892fc7c8c5844f54270733b1ee8", + "local_sha256": "6bd90d22c28dc4abe2335a07291bea6c8fad8892fc7c8c5844f54270733b1ee8" + }, + { + "path": "Sources/Textual/StructuredText/Style/CodeBlockProxy.swift", + "upstream_sha256": "e4056c60c210c40150cc7fae69423e2352c76cae97cc6df8a71b2b7405716c27", + "local_sha256": "e4056c60c210c40150cc7fae69423e2352c76cae97cc6df8a71b2b7405716c27" + }, + { + "path": "Sources/Textual/StructuredText/Style/CodeBlockStyle.swift", + "upstream_sha256": "50e327aaa7f8b8c91302457f8f0453034f5b3804dabea37f4ed0319ea0817615", + "local_sha256": "50e327aaa7f8b8c91302457f8f0453034f5b3804dabea37f4ed0319ea0817615" + }, + { + "path": "Sources/Textual/StructuredText/Style/Default/DefaultBlockQuoteStyle.swift", + "upstream_sha256": "fc4010488d7e77e55d550c01d73121b86df853ca77b58d53f64fe3e601dab9e5", + "local_sha256": "fc4010488d7e77e55d550c01d73121b86df853ca77b58d53f64fe3e601dab9e5" + }, + { + "path": "Sources/Textual/StructuredText/Style/Default/DefaultCodeBlockStyle.swift", + "upstream_sha256": "cb00fcb603b477bcaa42af6bb8b61443806c76948cf5433a66815351763b8a66", + "local_sha256": "cb00fcb603b477bcaa42af6bb8b61443806c76948cf5433a66815351763b8a66" + }, + { + "path": "Sources/Textual/StructuredText/Style/Default/DefaultHeadingStyle.swift", + "upstream_sha256": "fae638508cc2a15f3503efbb3b7ddb764626cd6ff2605159e091619f4d8decd9", + "local_sha256": "fae638508cc2a15f3503efbb3b7ddb764626cd6ff2605159e091619f4d8decd9" + }, + { + "path": "Sources/Textual/StructuredText/Style/Default/DefaultListItemStyle.swift", + "upstream_sha256": "c62ae85e9e9ae5bdcdf4d62d4d530749cbeb6a84795a594f438c8f8f70d57fd8", + "local_sha256": "c62ae85e9e9ae5bdcdf4d62d4d530749cbeb6a84795a594f438c8f8f70d57fd8" + }, + { + "path": "Sources/Textual/StructuredText/Style/Default/DefaultParagraphStyle.swift", + "upstream_sha256": "bc2dcf7e00fafb2af56d310541f8ce9b87d4891acd5c07e3874766a0a9b5a7e9", + "local_sha256": "bc2dcf7e00fafb2af56d310541f8ce9b87d4891acd5c07e3874766a0a9b5a7e9" + }, + { + "path": "Sources/Textual/StructuredText/Style/Default/DefaultStyle.swift", + "upstream_sha256": "ec9dc56b7569a48dcb69662b4a3a47a552fae808af389f21fbce08ea111aed98", + "local_sha256": "ec9dc56b7569a48dcb69662b4a3a47a552fae808af389f21fbce08ea111aed98" + }, + { + "path": "Sources/Textual/StructuredText/Style/Default/DefaultTableCellStyle.swift", + "upstream_sha256": "6be859d593553a8280853f6910fe9385bc0385143889177d29c5dda56e637df5", + "local_sha256": "6be859d593553a8280853f6910fe9385bc0385143889177d29c5dda56e637df5" + }, + { + "path": "Sources/Textual/StructuredText/Style/Default/DefaultTableStyle.swift", + "upstream_sha256": "6b4badf53b4b5572e8fae3165dc0bae82afe02769de76080737bd4dd8386af8c", + "local_sha256": "6b4badf53b4b5572e8fae3165dc0bae82afe02769de76080737bd4dd8386af8c" + }, + { + "path": "Sources/Textual/StructuredText/Style/Default/DividerThematicBreakStyle.swift", + "upstream_sha256": "06b62e386cbb59248dac490dadb9048cf9b46362d6bbdcef27a5af3514f600b0", + "local_sha256": "06b62e386cbb59248dac490dadb9048cf9b46362d6bbdcef27a5af3514f600b0" + }, + { + "path": "Sources/Textual/StructuredText/Style/Default/DynamicColor+Default.swift", + "upstream_sha256": "47f466de41afab5a3ba4c9ab7a00fc52dce315ab245b256de28f51f6303d8370", + "local_sha256": "47f466de41afab5a3ba4c9ab7a00fc52dce315ab245b256de28f51f6303d8370" + }, + { + "path": "Sources/Textual/StructuredText/Style/Default/OverflowTableStyle.swift", + "upstream_sha256": "2055509570ad72f7fb0c37a1defc388cd7460f057f9bb88df802754d0c6cae63", + "local_sha256": "2055509570ad72f7fb0c37a1defc388cd7460f057f9bb88df802754d0c6cae63" + }, + { + "path": "Sources/Textual/StructuredText/Style/GitHub/DynamicColor+GitHub.swift", + "upstream_sha256": "e4e97aa3bdb4d7440fefbafc4cbd04fd0848d24dfc3ddf3ee214a850927096c3", + "local_sha256": "e4e97aa3bdb4d7440fefbafc4cbd04fd0848d24dfc3ddf3ee214a850927096c3" + }, + { + "path": "Sources/Textual/StructuredText/Style/GitHub/GitHubBlockQuoteStyle.swift", + "upstream_sha256": "5cbd495c8d149365b3fd1b5cee98f5fe4a3a0d3e444deb21df35602ef7809483", + "local_sha256": "5cbd495c8d149365b3fd1b5cee98f5fe4a3a0d3e444deb21df35602ef7809483" + }, + { + "path": "Sources/Textual/StructuredText/Style/GitHub/GitHubCodeBlockStyle.swift", + "upstream_sha256": "b10c6d844518fcaaad4bcbfbfecd73136fd43a4f204b5cf077d51c49679bd6c5", + "local_sha256": "b10c6d844518fcaaad4bcbfbfecd73136fd43a4f204b5cf077d51c49679bd6c5" + }, + { + "path": "Sources/Textual/StructuredText/Style/GitHub/GitHubHeadingStyle.swift", + "upstream_sha256": "047a67e912c52a7810621fb2a3143d54415c94b3c3625a8619f6086daeca630b", + "local_sha256": "047a67e912c52a7810621fb2a3143d54415c94b3c3625a8619f6086daeca630b" + }, + { + "path": "Sources/Textual/StructuredText/Style/GitHub/GitHubParagraphStyle.swift", + "upstream_sha256": "1026396ce0e727ab620e5349d42fe585cd3fe420f4d570b82df08fa7543cef69", + "local_sha256": "1026396ce0e727ab620e5349d42fe585cd3fe420f4d570b82df08fa7543cef69" + }, + { + "path": "Sources/Textual/StructuredText/Style/GitHub/GitHubStyle.swift", + "upstream_sha256": "f2bbc522200fb2c7280be436481c0abf056c116ae4b9a7ebef43b1199f658566", + "local_sha256": "f2bbc522200fb2c7280be436481c0abf056c116ae4b9a7ebef43b1199f658566" + }, + { + "path": "Sources/Textual/StructuredText/Style/GitHub/GitHubTableCellStyle.swift", + "upstream_sha256": "18300fdac59047c2717e8d47c2354383f7ad0d3fb2a53cd53f343fb395ab13ff", + "local_sha256": "18300fdac59047c2717e8d47c2354383f7ad0d3fb2a53cd53f343fb395ab13ff" + }, + { + "path": "Sources/Textual/StructuredText/Style/GitHub/GitHubTableStyle.swift", + "upstream_sha256": "c8e4e8b126573b6046808a32279e0f4778f80afaf355b7e0c56496f460b1db54", + "local_sha256": "c8e4e8b126573b6046808a32279e0f4778f80afaf355b7e0c56496f460b1db54" + }, + { + "path": "Sources/Textual/StructuredText/Style/GitHub/GitHubThematicBreak.swift", + "upstream_sha256": "cbcd93d80b4577935649bf2c051d5f69ba1705902751d6d2661cadde48458679", + "local_sha256": "cbcd93d80b4577935649bf2c051d5f69ba1705902751d6d2661cadde48458679" + }, + { + "path": "Sources/Textual/StructuredText/Style/HeadingStyle.swift", + "upstream_sha256": "aa5ec2278073303aff9eba6eab59f60341298c48656bc62ff7754943b1224635", + "local_sha256": "aa5ec2278073303aff9eba6eab59f60341298c48656bc62ff7754943b1224635" + }, + { + "path": "Sources/Textual/StructuredText/Style/ListItemStyle.swift", + "upstream_sha256": "f62250aebe73172d5b42e5453d28793b37f8c40463726ebc33142ab3aa993a84", + "local_sha256": "f62250aebe73172d5b42e5453d28793b37f8c40463726ebc33142ab3aa993a84" + }, + { + "path": "Sources/Textual/StructuredText/Style/OrderedListMarker.swift", + "upstream_sha256": "ebf3919391896da40d45024cbf7fb45f5569e9ac55b2fdea31aa36ffb96fc0ef", + "local_sha256": "ebf3919391896da40d45024cbf7fb45f5569e9ac55b2fdea31aa36ffb96fc0ef" + }, + { + "path": "Sources/Textual/StructuredText/Style/Overflow.swift", + "upstream_sha256": "ff9b7d6ea701b1613324b6ce5715d176233c23ebcff41a8252885dc9601466a6", + "local_sha256": "b76db1e1943cffc4ab88b76bc2cc3c1cd5a424dd4bc3e1169367d07465e4b196" + }, + { + "path": "Sources/Textual/StructuredText/Style/ParagraphStyle.swift", + "upstream_sha256": "9fb0fa98b7779b2715918ed984d30ee56cc60df86ad5ae16da0d1151759c1fb6", + "local_sha256": "9fb0fa98b7779b2715918ed984d30ee56cc60df86ad5ae16da0d1151759c1fb6" + }, + { + "path": "Sources/Textual/StructuredText/Style/Style.swift", + "upstream_sha256": "e6646ddf07631afb0d25390c29c9373e62ccc049143d08f81779656edc96fa03", + "local_sha256": "e6646ddf07631afb0d25390c29c9373e62ccc049143d08f81779656edc96fa03" + }, + { + "path": "Sources/Textual/StructuredText/Style/TableCellStyle.swift", + "upstream_sha256": "74285756cddb10dcc57f8b7b0b0e30276ed92645bf4d65ae515952810409ab1f", + "local_sha256": "74285756cddb10dcc57f8b7b0b0e30276ed92645bf4d65ae515952810409ab1f" + }, + { + "path": "Sources/Textual/StructuredText/Style/TableLayout.swift", + "upstream_sha256": "8fcf75f3d47bfe8a3a3814afa3fafa8521db2cfb3de01f383b5ec5a08297d4aa", + "local_sha256": "8fcf75f3d47bfe8a3a3814afa3fafa8521db2cfb3de01f383b5ec5a08297d4aa" + }, + { + "path": "Sources/Textual/StructuredText/Style/TableStyle.swift", + "upstream_sha256": "02df543d63caeab909a721957e84379fb702979050fff0c1d406b790282e5533", + "local_sha256": "02df543d63caeab909a721957e84379fb702979050fff0c1d406b790282e5533" + }, + { + "path": "Sources/Textual/StructuredText/Style/ThematicBreakStyle.swift", + "upstream_sha256": "44c45981069ee0c571fe2de8a1ced6373267335c3dacc34b646ebaafdbb1e64e", + "local_sha256": "44c45981069ee0c571fe2de8a1ced6373267335c3dacc34b646ebaafdbb1e64e" + }, + { + "path": "Sources/Textual/StructuredText/Style/UnorderedListMarker.swift", + "upstream_sha256": "e6eb544f63329f6f0ed3a3583644d4879df8672c3f1c78772a3ffc05562e350c", + "local_sha256": "e6eb544f63329f6f0ed3a3583644d4879df8672c3f1c78772a3ffc05562e350c" + }, + { + "path": "Sources/Textual/TextEnvironmentValues.swift", + "upstream_sha256": "529835802d14ad5214b015d066d6d2e840139771508d03f68a54676492bf1763", + "local_sha256": "529835802d14ad5214b015d066d6d2e840139771508d03f68a54676492bf1763" + }, + { + "path": "Sources/Textual/TextProperty/Color/BackgroundColorProperty.swift", + "upstream_sha256": "c2d8d08622672a864c04720b5ad990f704b74c3df60772ecba9a0accf15e93f3", + "local_sha256": "c2d8d08622672a864c04720b5ad990f704b74c3df60772ecba9a0accf15e93f3" + }, + { + "path": "Sources/Textual/TextProperty/Color/ForegroundColorProperty.swift", + "upstream_sha256": "3e42917538bdd2ae911a51b64bf867ca297492f440be3751b97623fa77a0bc12", + "local_sha256": "3e42917538bdd2ae911a51b64bf867ca297492f440be3751b97623fa77a0bc12" + }, + { + "path": "Sources/Textual/TextProperty/Font/BoldProperty.swift", + "upstream_sha256": "a83a6d1cef8c41f26a8933ca9ccbd76cf1fca362e0aca17c1fc81721188d6361", + "local_sha256": "a83a6d1cef8c41f26a8933ca9ccbd76cf1fca362e0aca17c1fc81721188d6361" + }, + { + "path": "Sources/Textual/TextProperty/Font/FontLeadingProperty.swift", + "upstream_sha256": "356aed7420af4a6d650b22d27534498fad4cf125bb1caf5e90af1c84f22b1c7a", + "local_sha256": "356aed7420af4a6d650b22d27534498fad4cf125bb1caf5e90af1c84f22b1c7a" + }, + { + "path": "Sources/Textual/TextProperty/Font/FontProperty.swift", + "upstream_sha256": "8ede8e0a5db2aceecf52ea5a884bcf28717810c2a9c4035dbfadfe155ab2401f", + "local_sha256": "8ede8e0a5db2aceecf52ea5a884bcf28717810c2a9c4035dbfadfe155ab2401f" + }, + { + "path": "Sources/Textual/TextProperty/Font/FontScaleProperty.swift", + "upstream_sha256": "25c9a51ddaf5e23b0bf113fd68cfb2e037498a318619dd5b761ec763ebab6478", + "local_sha256": "25c9a51ddaf5e23b0bf113fd68cfb2e037498a318619dd5b761ec763ebab6478" + }, + { + "path": "Sources/Textual/TextProperty/Font/FontWeightProperty.swift", + "upstream_sha256": "fda98f5176b16ae4562ac38adbfd369b48ffcb7add6b7cc8eb0c95cb1538418c", + "local_sha256": "fda98f5176b16ae4562ac38adbfd369b48ffcb7add6b7cc8eb0c95cb1538418c" + }, + { + "path": "Sources/Textual/TextProperty/Font/FontWidthProperty.swift", + "upstream_sha256": "d055350ce9e28a955418f027a7d981d876c05d24064b1e1b59fee803a2edcd9d", + "local_sha256": "d055350ce9e28a955418f027a7d981d876c05d24064b1e1b59fee803a2edcd9d" + }, + { + "path": "Sources/Textual/TextProperty/Font/ItalicProperty.swift", + "upstream_sha256": "b1e12c2148e841c98a618ba3cc129b07040075d51612dcacf5bc4aa9ed00f4c1", + "local_sha256": "b1e12c2148e841c98a618ba3cc129b07040075d51612dcacf5bc4aa9ed00f4c1" + }, + { + "path": "Sources/Textual/TextProperty/Font/LowercaseSmallCapsProperty.swift", + "upstream_sha256": "13f2ae9b03dafbcd3acf494c86904fef9d74b7bf77c4bf1ca5894d12cc4e3d9d", + "local_sha256": "13f2ae9b03dafbcd3acf494c86904fef9d74b7bf77c4bf1ca5894d12cc4e3d9d" + }, + { + "path": "Sources/Textual/TextProperty/Font/MonospacedDigitProperty.swift", + "upstream_sha256": "c7605627baa98312e183f9a3431e28e9c64b49cd654f14d8e904a06ada50c200", + "local_sha256": "c7605627baa98312e183f9a3431e28e9c64b49cd654f14d8e904a06ada50c200" + }, + { + "path": "Sources/Textual/TextProperty/Font/MonospacedProperty.swift", + "upstream_sha256": "8960c5c5403e12173b47ef9fd425295cbecb3fb848cb35d8f3aacdf9ab62398d", + "local_sha256": "8960c5c5403e12173b47ef9fd425295cbecb3fb848cb35d8f3aacdf9ab62398d" + }, + { + "path": "Sources/Textual/TextProperty/Font/SmallCapsProperty.swift", + "upstream_sha256": "1414d54f272ab3b5cab9797997ffa637f6b2ea93ff47a5a25d9ab8cfc6eedbb4", + "local_sha256": "1414d54f272ab3b5cab9797997ffa637f6b2ea93ff47a5a25d9ab8cfc6eedbb4" + }, + { + "path": "Sources/Textual/TextProperty/Font/UppercaseSmallCapsProperty.swift", + "upstream_sha256": "f5b146f5beb717b1110b230c3685958882df0b6a66da628b7efbd1ed43726371", + "local_sha256": "f5b146f5beb717b1110b230c3685958882df0b6a66da628b7efbd1ed43726371" + }, + { + "path": "Sources/Textual/TextProperty/LineStyle/StrikethroughStyleProperty.swift", + "upstream_sha256": "5b3e4d668377d06ee7e1647d5bf42dfbf40e4fffdfafa18934803e67a61b10ed", + "local_sha256": "5b3e4d668377d06ee7e1647d5bf42dfbf40e4fffdfafa18934803e67a61b10ed" + }, + { + "path": "Sources/Textual/TextProperty/LineStyle/UnderlineStyleProperty.swift", + "upstream_sha256": "99373fda0293a988baa00eb0a755991382ae58fd1e25f1fe23f6e61afb71d341", + "local_sha256": "99373fda0293a988baa00eb0a755991382ae58fd1e25f1fe23f6e61afb71d341" + }, + { + "path": "Sources/Textual/TextProperty/Space/BaselineOffsetProperty.swift", + "upstream_sha256": "3b4e6e10acce959feb37074c9ea3a900df00828f2cd340122a7dc248bc31877d", + "local_sha256": "3b4e6e10acce959feb37074c9ea3a900df00828f2cd340122a7dc248bc31877d" + }, + { + "path": "Sources/Textual/TextProperty/Space/KerningProperty.swift", + "upstream_sha256": "18dada1a3271ae96300f4d86eef4d77f94ef00603bbb8f5f0241b1cd0ed17487", + "local_sha256": "18dada1a3271ae96300f4d86eef4d77f94ef00603bbb8f5f0241b1cd0ed17487" + }, + { + "path": "Sources/Textual/TextProperty/Space/TrackingProperty.swift", + "upstream_sha256": "5a04232cd618a2d2748df7035178c5d8631b531d96ec4a4f3f92d5c0271e7542", + "local_sha256": "5a04232cd618a2d2748df7035178c5d8631b531d96ec4a4f3f92d5c0271e7542" + }, + { + "path": "Sources/Textual/TextProperty/TextProperty.swift", + "upstream_sha256": "434d009fa2f451717587b57d2231dfef8a63945cf090ccec6c8bdf6d7e3182cf", + "local_sha256": "434d009fa2f451717587b57d2231dfef8a63945cf090ccec6c8bdf6d7e3182cf" + }, + { + "path": "Sources/Textual/TextProperty/TupleTextProperty.swift", + "upstream_sha256": "ed267c4e4b0a51d84e7c451b3b857376b83871d850cc4728836ca9e8a41ef6e9", + "local_sha256": "ed267c4e4b0a51d84e7c451b3b857376b83871d850cc4728836ca9e8a41ef6e9" + }, + { + "path": "Sources/Textual/TextualNamespace.swift", + "upstream_sha256": "c1da49bd9c2c076cec144210470f890a7d7f34ed6bd411b51fda83e924d547cf", + "local_sha256": "c1da49bd9c2c076cec144210470f890a7d7f34ed6bd411b51fda83e924d547cf" + }, + { + "path": "Sources/Textual/View+Textual.swift", + "upstream_sha256": "36fefb072da676deef914f086ecdecdc1960b2d9fc5c54dee87e5c29bc414709", + "local_sha256": "36fefb072da676deef914f086ecdecdc1960b2d9fc5c54dee87e5c29bc414709" + } + ] +} diff --git a/apps/maple-ios/docs/rendering.md b/apps/maple-ios/docs/rendering.md new file mode 100644 index 000000000..31b50638f --- /dev/null +++ b/apps/maple-ios/docs/rendering.md @@ -0,0 +1,146 @@ +# Message rendering + +`MessageView(text:theme:font:accountGeneration:onOpenLink:)` is the shared +SwiftUI message component on iOS26. Pass the active account's revocable generation. +The parent owns confirmation before opening any callback URL. The renderer admits +only HTTP(S) links without embedded credentials and provides no image loading. + +Foundation Markdown plus pinned Textual handles structure, native text selection, +lists, tables and inline styling. App normalization matches Research's Markdown +configuration: soft source line breaks become rendered line breaks, real GFM task +markers become read-only checked/unchecked symbols, and accidental indentation +becomes ordinary prose with inline formatting. Source positions distinguish +escaped task markers and quoted/nested fences. Inline code and link destinations +retain their parsed meaning; normalization does not rewrite their source globally. +Fence classification reads the parsed node's opening source line, including when +Foundation reports an unusable ending position at a following blank line. Its +lookup honors CRLF, LF and CR line endings without rewriting the original source. + +Tables measure bounded cell widths before the horizontal scroller proposes its +viewport width. Cells scale with Dynamic Type; the outer grid keeps each Markdown +column's alignment and lets wide tables scroll rather than squeeze every column. + +The chat transcript takes its width from the detail viewport, capped at 850 +points and centered in wider windows. Its 20-point horizontal insets and leading +alignment stay fixed as streamed rich blocks prepare or grow. Wide tables and +code scroll within the reply instead of moving the transcript column. +Programmatic vertical scrolls specify a zero horizontal offset. A content-local +UIKit marker also keeps the outer transcript scroll view at its horizontal inset +when SwiftUI repositions it as rich blocks grow, preserving its current vertical +offset. It observes only its nearest ancestor scroll view while this chat and +scene are active; nested table/code scrollers retain their own horizontal offset. + +Fenced code uses the app's bounded native lexer and explicit copy action. Unknown +languages and excessive highlight work retain the entire code as plain text. +Every actual code block, including quoted and nested fences, receives an owned +hint so it cannot enter implicit math or JavaScript highlighting paths in the +dependency. Fenced `math` remains literal code; indentation alone does not suppress +ordinary prose or formulas. + +Textual's exact pinned runtime is vendored under `Vendor/textual`, with the MIT +license and per-file upstream/local hashes. Three bounded Copy changes preserve +explicit breaks as LF in plain text, preserve literal LF as `
    ` in ordinary +HTML text nodes, and publish plain text as concrete UTF-8 bytes alongside HTML. +Native selection can preserve visible LF while dropping its inline break intent; +HTML must retain that break even when it arrives without the semantic attribute. +Escaping, code whitespace and the upstream selection implementation are unchanged. +Runtime fixtures exercise the rendered-text-to-formatter and system-pasteboard +boundaries separately from formatter tests. The upstream standalone snapshot-test +tooling is omitted; app regression tests cover the patches. See `Vendor/textual/MAPLE-PATCH.md` and `UPSTREAM.json` for provenance. +Do not patch generated SwiftPM checkouts or reformat the vendored runtime. + +Native partial-selection Copy writes plain text and HTML to the system pasteboard +through Textual. The app's explicit whole-message and code Copy actions use +local-only pasteboard entries with a two-minute expiry. + +A message-owned actor parses Markdown away from the UI actor. A main-actor owner +throttles publication without restarting its deadline for every streamed token. +Equation enrichment is separate and canceled by a new revision. Generation +revocation clears retained output and rejects late preparation/enrichment results. +Disappearance cancels work. While the exact account generation and selected chat +owner remain current, completed message-local projections survive lazy scrolling +and temporary menu occlusion. Revisiting a completed reply reuses its formatting +instead of switching to raw Markdown and changing its height. Incomplete work +still restarts on return; replaced owners clear or release their view state. +There is no document or formula cache shared across accounts. + +## Equations + +The app recognizes dollar, double-dollar, `\(...\)` and `\[...\]` delimiters. +Numeric-leading single-dollar expressions stay literal for compatibility with the +existing Research renderer; explicit backslash delimiters can express those +formulas. Inline/fenced code and link destinations are never interpreted as math. Incomplete +or rejected formulas retain their complete original source, including backslashes. + +The evaluated native equation libraries do not cover the required command set: +SwiftUIMath0.1.0 and SwiftMath1.7.3 reject `operatorname`, `Bigl`, `tag`, `boxed` +and `newcommand`. An iOS26.5 simulator probe confirmed blank output for these +commands with SwiftUIMath, while a fraction/square-root control rendered. + +Only equations use bundled KaTeX0.18.10 in a short-lived WKWebView. The transcript +remains native. The equation process receives formula strings and a display-mode +boolean through typed JavaScript arguments. It has no account identity, SDK, +credentials, message handlers, arbitrary HTML input or network loader. A +nonpersistent data store, restrictive CSP, WebKit content blocker, navigation +policy and exact packaged-resource allowlist enforce the boundary. External +images, fonts, frames, media, fetches, forms, popups and navigation are denied. +Only packaged JS, CSS and woff2 fonts are admitted by the custom URL scheme. + +Each `MessageView` retains its own model and noninteractive UIKit host. The +renderer attaches outside that host's exact active window before loading the +bundled page; initial mounting shares the existing 12-second navigation bound. +Reparenting, removal, scene suspension and cancellation invalidate that host's +renderer. No application window search or shared formula host is used. + +After font-ready measurement, one bounded snapshot temporarily detaches only the +owned web view and uses `afterScreenUpdates:false` for page capture. Its exact +host, container and active window must remain current before reattachment or +publication; failure and cancellation leave the web view detached. The existing +three-second capture bound, eight-second enrichment budget and raster leases +remain unchanged. Equation pixels stay in memory. + +KaTeX uses `trust:false`, strict errors, fresh macro dictionaries, a finite +expansion limit and bounded size. Input, depth, cells, generated HTML, DOM nodes, +image dimensions and execution time are bounded. Successful equation snapshots +stay in memory with their owning message; original TeX supplies copy and +accessibility text. Parsed equation accessibility sources remain available while +rasterization is pending or a formula retains its original source. Cancellation and timeout discard the web view. A global +content-free admission queue permits one live renderer and at most16 waiting +jobs. Reference-counted content-free leases bound retained decoded raster cost +to16MiB across messages; failed admission or reservation leaves selectable TeX. +The WebKit content-rule store contains only the fixed network policy. + +Asset provenance, tarball integrity and license notices are bundled under +`Sources/MapleAppUI/Resources/EquationRenderer`. KaTeX's +[security guidance](https://katex.org/docs/security.html) and +[rendering options](https://katex.org/docs/options.html) describe the upstream +controls; native resource and lifecycle restrictions are app-owned. + +## Verification + +Run the component unit recipe in the pinned Apple shell. Renderer tests cover +source-preserving tokenization, final Markdown output, safe URLs, GFM intent, +code ownership, soft/hard line-break Copy in both export formats, escaped task +markers, indented prose, streaming publication, generation fencing, work admission, +raster reservations and the exact bundled KaTeX trust/macro configuration. + +The debug-only `--maple-renderer-fixture` launch argument presents synthetic +content without account access. `RendererUITests` exercises native selection, +streamed append and code copy on the real app. Native simulator checks must also +verify equation pixels, blocked network attempts, light/dark output, Dynamic +Type, RTL and selection across paragraph/code/table boundaries. Build or unit +success alone does not prove those runtime behaviors. + +`ShippingViewportUITests/testCompletedRichRepliesStayPreparedDuringRepeatedHistoryScrolling` +uses six completed rich turns through the production chat screen. It checks +repeated earlier/later scrolling, native code/link controls, settled geometry, +and the exact prepared projection retained when a lazy row disappears. Its +debug-only diagnostics contain counts and booleans, never message contents. + +`ShippingViewportUITests/testStreamingRichReplyKeepsTheTranscriptColumnAndActionsInPlace` +streams prose, a wide table and code through the production chat screen. It checks +the horizontal geometry of a preceding tool row, the first paragraph and reply +actions during the run and after completion on iPhone and iPad, then exercises +nested table scrolling and vertical timeline dragging. Layout extrema remain +diagnostic: geometry can expose an attempted offset before the native guard +corrects it within the same update, so displayed positions are checked separately. diff --git a/apps/maple-ios/scripts/build.sh b/apps/maple-ios/scripts/build.sh new file mode 100755 index 000000000..b96a65904 --- /dev/null +++ b/apps/maple-ios/scripts/build.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail +app_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +mode="${1:-build}" +profile="${2:-local}" +case "$(xcodebuild -version | awk '/^Xcode / {print $2; exit}')" in + 26.5|26.5.*) ;; + *) echo "Select Xcode 26.5 before building Maple iOS." >&2; exit 1 ;; +esac + +case "$mode" in + unit) + xcrun swift-format lint --strict --configuration "$app_dir/.swift-format" --recursive \ + "$app_dir/Package.swift" "$app_dir/App" "$app_dir/Sources" "$app_dir/Tests" "$app_dir/UITests" + # Nix may export a Command Line Tools SDKROOT. SwiftUI macros need the + # macOS SDK supplied by the selected Xcode, matching xcrun's compiler. + swift_args=(--package-path "$app_dir" --sdk "$(xcrun --sdk macosx --show-sdk-path)") + if [[ -n "${MAPLE_IOS_SWIFT_SCRATCH_PATH:-}" ]]; then + swift_args+=(--scratch-path "$MAPLE_IOS_SWIFT_SCRATCH_PATH") + fi + env -u MAPLE_RUN_LIVE_ATTESTATION -u MAPLE_SWIFT_INTEGRATION_URL -u MAPLE_SWIFT_DEV_INTEGRATION \ + -u MAPLE_IOS_ACCOUNT_LIVE -u MAPLE_IOS_AUTH_LIVE -u RUN_LIVE_AI \ + -u LD -u LDPLUSPLUS -u CC -u CXX xcrun swift test "${swift_args[@]}" -Xswiftc -warnings-as-errors + exit 0 + ;; + build|device-build|runtime-build|test-simulator) ;; + *) echo "Usage: build.sh {unit|build|device-build|runtime-build|test-simulator} [local|pr|release]" >&2; exit 2 ;; +esac +case "$profile" in + local) configuration=Local ;; + pr) configuration=PR ;; + release) configuration=Release ;; + *) echo "Expected profile local, pr, or release." >&2; exit 2 ;; +esac +if [[ "$profile" != pr && ! -f "$app_dir/Config/Local.generated.xcconfig" ]]; then + echo "Run configure-local.py with explicit public local service inputs first." >&2 + exit 1 +fi +destination='generic/platform=iOS Simulator' +[[ "$mode" == device-build ]] && destination='generic/platform=iOS' +derived="${MAPLE_IOS_DERIVED_DATA:-$app_dir/.build/xcode-$profile}" +build_settings=(CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO) +action=build +if [[ "$mode" == runtime-build || "$mode" == test-simulator ]]; then + if [[ "$profile" != local || ! "${MAPLE_IOS_SIMULATOR_ID:-}" =~ ^[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}$ ]]; then + echo "Simulator runtime checks require profile local and an explicit MAPLE_IOS_SIMULATOR_ID." >&2 + exit 2 + fi + destination="platform=iOS Simulator,id=$MAPLE_IOS_SIMULATOR_ID" + # Xcode supplies its simulator application identity. No team, certificate, + # provisioning profile or device/distribution signature is used here. + build_settings=(CODE_SIGNING_ALLOWED=YES CODE_SIGNING_REQUIRED=YES CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM=) + build_settings+=(ONLY_ACTIVE_ARCH=YES SWIFT_OPTIMIZATION_LEVEL=-Onone + SWIFT_COMPILATION_MODE=singlefile 'SWIFT_ACTIVE_COMPILATION_CONDITIONS=$(inherited) DEBUG') + action=build-for-testing + [[ "$mode" == test-simulator ]] && action=test +fi +env -u LD -u LDPLUSPLUS -u CC -u CXX \ + xcodebuild -jobs 2 -project "$app_dir/Maple.xcodeproj" -scheme Maple -configuration "$configuration" \ + -destination "$destination" -derivedDataPath "$derived" \ + "${build_settings[@]}" "$action" diff --git a/apps/maple-ios/scripts/configure-local.py b/apps/maple-ios/scripts/configure-local.py new file mode 100755 index 000000000..bc456b8c4 --- /dev/null +++ b/apps/maple-ios/scripts/configure-local.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Read only public client settings; never source a dotenv file or read credentials.""" + +import argparse +import os +from pathlib import Path +import tempfile +from urllib.parse import urlsplit +from uuid import UUID + + +PUBLIC_KEYS = { + "VITE_OPEN_SECRET_API_URL": "MAPLE_API_URL", + "VITE_MAPLE_BILLING_API_URL": "MAPLE_BILLING_URL", + "VITE_OS_FLAGS_BASE_URL": "MAPLE_FLAGS_URL", +} + + +def loopback_origin(value: str) -> str: + if any(char.isspace() or ord(char) < 32 for char in value): + raise ValueError("Local service URLs must be explicit HTTP loopback origins.") + try: + url = urlsplit(value) + valid = ( + url.scheme == "http" + and url.hostname in {"127.0.0.1", "localhost", "::1"} + and url.port is not None + and 0 < url.port <= 65535 + and url.username is None + and url.password is None + and url.path in {"", "/"} + and not url.query + and not url.fragment + ) + except ValueError: + valid = False + if not valid: + raise ValueError("Local service URLs must be explicit HTTP loopback origins.") + return value.rstrip("/") + + +def read_public_values(path: Path) -> dict[str, str]: + values = {} + for line in path.read_text(encoding="utf-8").splitlines(): + key, separator, value = line.partition("=") + key = key.strip() + if not separator or key not in PUBLIC_KEYS: + continue + if key in values: + raise ValueError("The input contains duplicate public endpoint settings.") + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + value = value[1:-1] + values[key] = loopback_origin(value) + if values.keys() != PUBLIC_KEYS.keys(): + raise ValueError("The input must define API, billing and flags public endpoints.") + return {PUBLIC_KEYS[key]: value for key, value in values.items()} + + +def configure(env_file: Path, client_id: str, auth_url: str, output: Path) -> None: + values = read_public_values(env_file) + values["MAPLE_AUTH_URL"] = loopback_origin(auth_url) + try: + values["MAPLE_CLIENT_ID"] = str(UUID(client_id)) + except ValueError: + raise ValueError("The public client ID must be a UUID.") from None + values["MAPLE_PROFILE"] = "local" + lines = ["// Generated public local settings. No account or provider credentials."] + # xcconfig treats // as a comment; the empty expansion preserves URL slashes. + lines.extend(f"{key} = {value.replace('://', ':/$()/')}" for key, value in sorted(values.items())) + output.parent.mkdir(parents=True, exist_ok=True) + if output.is_symlink(): + raise ValueError("The output must not be a symbolic link.") + descriptor, temporary = tempfile.mkstemp(prefix=".local-config-", dir=output.parent) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + stream.write("\n".join(lines) + "\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, output) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--env-file", type=Path, required=True) + parser.add_argument("--client-id", required=True, help="Public application client UUID, never an account credential") + parser.add_argument("--auth-url", required=True, help="Explicit loopback web authentication origin") + parser.add_argument("--output", type=Path, default=Path(__file__).resolve().parent.parent / "Config/Local.generated.xcconfig") + arguments = parser.parse_args() + try: + configure(arguments.env_file, arguments.client_id, arguments.auth_url, arguments.output) + except (OSError, UnicodeError, ValueError) as error: + # Do not echo source lines, environment values, or paths from untrusted input. + message = str(error) if isinstance(error, ValueError) else "Could not read or write local configuration." + parser.exit(1, message + "\n") + print("Local public configuration written. Account credentials are not included.") + + +if __name__ == "__main__": + main() diff --git a/apps/maple-ios/scripts/stage-fonts.sh b/apps/maple-ios/scripts/stage-fonts.sh new file mode 100755 index 000000000..8c76478f4 --- /dev/null +++ b/apps/maple-ios/scripts/stage-fonts.sh @@ -0,0 +1,8 @@ +#!/bin/sh +# Bundle licensed, tracked font inputs without conversion or network access. +set -eu +destination="$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH" +mkdir -p "$destination" +install -m 644 "$SCRIPT_INPUT_FILE_0" "$destination/Array-Regular.otf" +install -m 644 "$SCRIPT_INPUT_FILE_1" "$destination/Manrope-VariableFont_wght.ttf" +install -m 644 "$SCRIPT_INPUT_FILE_2" "$destination/GeistMono-Regular.ttf" diff --git a/apps/maple-ios/scripts/test_configure_local.py b/apps/maple-ios/scripts/test_configure_local.py new file mode 100644 index 000000000..03e74af5a --- /dev/null +++ b/apps/maple-ios/scripts/test_configure_local.py @@ -0,0 +1,56 @@ +import importlib.util +from pathlib import Path +import tempfile +import unittest + + +spec = importlib.util.spec_from_file_location("configure_local", Path(__file__).with_name("configure-local.py")) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + + +class ConfigureLocalTests(unittest.TestCase): + def test_only_public_values_are_written_without_shell_evaluation(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "input.env" + source.write_text( + "VITE_OPEN_SECRET_API_URL=http://127.0.0.1:31000\n" + "VITE_MAPLE_BILLING_API_URL='http://localhost:36000'\n" + "VITE_OS_FLAGS_BASE_URL=http://[::1]:41000\n" + "PRIVATE_CANARY=must-not-copy\n" + "IGNORED_COMMAND=$(touch should-not-exist)\n" + ) + target = root / "generated.xcconfig" + module.configure(source, "11111111-1111-4111-8111-111111111111", "http://127.0.0.1:35000", target) + text = target.read_text() + self.assertIn("MAPLE_API_URL = http:/$()/127.0.0.1:31000", text) + self.assertIn("MAPLE_PROFILE = local", text) + self.assertNotIn("must-not-copy", text) + self.assertNotIn("IGNORED_COMMAND", text) + self.assertEqual(target.stat().st_mode & 0o777, 0o600) + + def test_non_loopback_and_credential_bearing_urls_are_rejected(self): + for value in [ + "https://example.com", "http://example.com:31000", "http://127.0.0.1", + "http://127.0.0.1:0", "http://user:secret@127.0.0.1:31000", + "http://127.0.0.1:31000/private", "http://127.0.0.1:31000?token=secret", + "http://127.0.0.1:31000#fragment", "http://127.0.0.1:31000\nINJECT=1", + ]: + with self.subTest(value=value), self.assertRaises(ValueError): + module.loopback_origin(value) + + def test_missing_and_duplicate_settings_fail_closed(self): + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) / "input.env" + for text in [ + "VITE_OPEN_SECRET_API_URL=http://127.0.0.1:31000\n", + "VITE_OPEN_SECRET_API_URL=http://127.0.0.1:31000\n" * 2, + ]: + source.write_text(text) + with self.assertRaises(ValueError): + module.read_public_values(source) + + +if __name__ == "__main__": + unittest.main() diff --git a/flake.nix b/flake.nix index 1e8bf4a6d..7f2153663 100644 --- a/flake.nix +++ b/flake.nix @@ -779,6 +779,25 @@ touch "$out" ''; + sdk-swift = pkgs.runCommand "maple-swift-sdk-config-check" { + nativeBuildInputs = with pkgs; [ bash python3 yq-go ]; + src = ./.; + } '' + cd "$src" + python3 scripts/ci/test_sdk_swift.py + touch "$out" + ''; + + ios-native = pkgs.runCommand "maple-ios-native-config-check" { + nativeBuildInputs = with pkgs; [ bash python3 yq-go ]; + src = ./.; + } '' + cd "$src" + python3 scripts/ci/test_ios_native.py + python3 apps/maple-ios/scripts/test_configure_local.py + touch "$out" + ''; + release-metadata = pkgs.runCommand "maple-release-metadata-check" { nativeBuildInputs = commonPackages; src = ./.; @@ -824,6 +843,7 @@ ci-signed-android-release-rehearsal = mkNixApp "maple-ci-signed-android-release-rehearsal" "android" "./scripts/ci/signed-release-rehearsal.sh android"; } // lib.optionalAttrs pkgs.stdenv.isDarwin { + ci-ios-native = mkNixApp "maple-ci-ios-native" "apple" "./scripts/ci/ios-native.sh"; ci-ios-onnxruntime = mkNixApp "maple-ci-ios-onnxruntime" "apple" "./scripts/ci/ios-onnxruntime.sh"; ci-ios-pr = mkNixApp "maple-ci-ios-pr" "apple" "./scripts/ci/ios-pr.sh"; ci-ios-release = mkNixApp "maple-ci-ios-release" "apple" "./scripts/ci/ios-release.sh"; diff --git a/scripts/ci/change_detection.py b/scripts/ci/change_detection.py index d021127c3..2936e8823 100644 --- a/scripts/ci/change_detection.py +++ b/scripts/ci/change_detection.py @@ -31,7 +31,7 @@ "apps/maple-research/zapstore.yaml", } ) -INERT_PREFIXES = (".agents/", ".githooks/", "docs/", "apps/maple-research/docs/", "apps/maple-research/.githooks/", "services/updates/", "services/opensecret/", "apps/maple-agent/", "apps/maple-auth/") +INERT_PREFIXES = (".agents/", ".githooks/", "docs/", "apps/maple-research/docs/", "apps/maple-research/.githooks/", "services/updates/", "services/opensecret/", "apps/maple-agent/", "apps/maple-auth/", "apps/maple-ios/") PURE_FRONTEND_PREFIXES = ("apps/maple-research/frontend/public/", "apps/maple-research/frontend/src/") PURE_FRONTEND_FILES = frozenset({"apps/maple-research/frontend/icon.svg", "apps/maple-research/frontend/index.html"}) PROXY_RUNTIME_PREFIXES = ("proxy/src/",) @@ -68,6 +68,8 @@ ) WORKFLOW_ROUTES = { + ".github/workflows/ios-native.yml": frozenset(), + ".github/workflows/sdk-swift.yml": frozenset(), ".github/workflows/android-build.yml": frozenset({"android"}), ".github/workflows/android-pr-build.yml": frozenset({"android"}), ".github/workflows/app-change-detection.yml": NATIVE_PLATFORMS, @@ -78,6 +80,9 @@ } CI_SCRIPT_ROUTES = { + "scripts/ci/ios-native.sh": frozenset(), + "scripts/ci/sdk-swift.sh": frozenset(), + "scripts/ci/sdk-swift-backend.sh": frozenset(), "scripts/ci/_common.sh": frozenset({"frontend", *NATIVE_PLATFORMS, "ios_onnx"}), "scripts/ci/android-pr.sh": frozenset({"android"}), "scripts/ci/android-release.sh": frozenset({"android"}), diff --git a/scripts/ci/hook_change_detection.py b/scripts/ci/hook_change_detection.py index 862ce201d..ee14d62b3 100644 --- a/scripts/ci/hook_change_detection.py +++ b/scripts/ci/hook_change_detection.py @@ -20,6 +20,8 @@ "agent", "sdk_rust", "sdk_ts", + "sdk_swift", + "ios_native", "proxy", "opensecret", "updates", @@ -47,6 +49,8 @@ RESEARCH_INERT_FILES = frozenset({"apps/maple-research/deny.toml", "apps/maple-research/zapstore.yaml"}) AUTH_PREFIX = "apps/maple-auth/" +IOS_NATIVE_PREFIX = "apps/maple-ios/" +IOS_NATIVE_CHECK_FILES = frozenset({".github/workflows/ios-native.yml", "scripts/ci/ios-native.sh"}) AGENT_PREFIX = "apps/maple-agent/" AGENT_INERT_PREFIXES = ("docs/", ".githooks/") @@ -66,6 +70,8 @@ } ) SDK_RUST_PREFIX = "sdk/rust/" +SDK_SWIFT_PREFIX = "sdk/swift/" +SDK_SWIFT_CHECK_FILES = frozenset({".github/workflows/sdk-swift.yml", "scripts/ci/sdk-swift.sh", "scripts/ci/sdk-swift-backend.sh"}) SDK_SHARED_FILES = frozenset({"sdk/flake.nix", "sdk/flake.lock", "sdk/rust-toolchain.toml"}) SDK_INERT_PREFIXES = ("sdk/docs/", "sdk/test/", "sdk/.githooks/") SDK_INERT_FILES = frozenset({"sdk/deny.toml", "sdk/justfile"}) @@ -129,6 +135,13 @@ def classify_path(path: str) -> frozenset[str]: return frozenset() return frozenset({"auth"}) + if path in IOS_NATIVE_CHECK_FILES: + return frozenset({"ios_native", "repo"}) + if path.startswith(IOS_NATIVE_PREFIX): + if path.removeprefix(IOS_NATIVE_PREFIX).startswith(".githooks/"): + return frozenset() + return frozenset({"ios_native"}) + if path.startswith(AGENT_PREFIX): relative = path.removeprefix(AGENT_PREFIX) if relative.startswith(AGENT_INERT_PREFIXES): @@ -137,6 +150,12 @@ def classify_path(path: str) -> frozenset[str]: if path in SDK_SHARED_FILES: return frozenset({"sdk_rust", "sdk_ts"}) + if path in SDK_SWIFT_CHECK_FILES: + return frozenset({"sdk_swift", "repo"}) + if path.startswith(SDK_SWIFT_PREFIX): + if path.removeprefix(SDK_SWIFT_PREFIX).startswith(".githooks/"): + return frozenset() + return frozenset({"sdk_swift"}) if path in SDK_INERT_FILES or path.startswith(SDK_INERT_PREFIXES): return frozenset() if path.startswith(SDK_RUST_PREFIX): diff --git a/scripts/ci/ios-native.sh b/scripts/ci/ios-native.sh new file mode 100755 index 000000000..0ebdeb798 --- /dev/null +++ b/scripts/ci/ios-native.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Unsigned native app checks; host Xcode supplies Swift and swift-format. +set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +app_dir="$repo_root/apps/maple-ios" +mode="${1:-unit}" +case "$mode" in + unit|format|simulator-build|device-build|cross-build) ;; + *) echo "Usage: ios-native.sh {unit|format|simulator-build|device-build|cross-build}" >&2; exit 2 ;; +esac +if [[ "$(uname -s)" != Darwin ]]; then + echo "Maple iOS checks require macOS and Xcode 26.5." >&2 + exit 1 +fi +case "$(xcodebuild -version | awk '/^Xcode / {print $2; exit}')" in + 26.5|26.5.*) ;; + *) echo "Select Xcode 26.5 before running Maple iOS checks." >&2; exit 1 ;; +esac + +# Never turn hermetic checks into developer-local service or credential access. +# Only variable names are read here; values are neither printed nor sourced. +while IFS= read -r variable; do + case "$variable" in + VITE_*|MAPLE_SWIFT_*|MAPLE_IOS_ACCOUNT_*|MAPLE_IOS_AUTH_*|MAPLE_RUN_LIVE_ATTESTATION|RUN_LIVE_AI|MAPLE_PROFILE|MAPLE_API_URL|MAPLE_BILLING_URL|MAPLE_FLAGS_URL|MAPLE_AUTH_URL|MAPLE_CLIENT_ID|MAPLE_CALLBACK_SCHEME) + unset "$variable" ;; + esac +done < <(compgen -v) +unset LD LDPLUSPLUS CC CXX + +case "$mode" in + unit) bash "$app_dir/scripts/build.sh" unit ;; + format) + xcrun swift-format format --configuration "$app_dir/.swift-format" --in-place --recursive \ + "$app_dir/Package.swift" "$app_dir/App" "$app_dir/Sources" "$app_dir/Tests" "$app_dir/UITests" + ;; + simulator-build) bash "$app_dir/scripts/build.sh" build pr ;; + device-build) bash "$app_dir/scripts/build.sh" device-build pr ;; + cross-build) + args=(--package-path "$app_dir" -c release -Xswiftc -warnings-as-errors) + if [[ -n "${MAPLE_IOS_SWIFT_SCRATCH_PATH:-}" ]]; then + args+=(--scratch-path "$MAPLE_IOS_SWIFT_SCRATCH_PATH") + fi + xcrun swift build "${args[@]}" --triple arm64-apple-ios26.0-simulator \ + --sdk "$(xcrun --sdk iphonesimulator --show-sdk-path)" + xcrun swift build "${args[@]}" --triple arm64-apple-ios26.0 \ + --sdk "$(xcrun --sdk iphoneos --show-sdk-path)" + ;; +esac diff --git a/scripts/ci/ios_native_change_detection.py b/scripts/ci/ios_native_change_detection.py new file mode 100755 index 000000000..fc9ba1f55 --- /dev/null +++ b/scripts/ci/ios_native_change_detection.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Select native iOS validation without Research or SDK-consumer fanout.""" + +from __future__ import annotations + +import argparse +from collections.abc import Iterable +import sys + +from hook_change_detection import classify_path + + +SHARED_INPUTS = frozenset({ + "flake.nix", "flake.lock", "scripts/ci/hook_change_detection.py", + "scripts/ci/ios_native_change_detection.py", "scripts/ci/test_ios_native.py", + "apps/maple-agent/app/assets/fonts/Array-Regular.otf", + "apps/maple-research/frontend/public/fonts/Manrope-VariableFont_wght.ttf", +}) + + +def selected(paths: Iterable[str]) -> bool: + # SDK publication and upgrading the consumer are independent decisions. + # Even an active local SDK link is selected through the app manifest lane. + return any(path in SHARED_INPUTS or "ios_native" in classify_path(path) for path in paths) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--all", action="store_true") + args = parser.parse_args() + if args.all: + enabled = True + else: + paths = (value.decode("utf-8", errors="surrogateescape") + for value in sys.stdin.buffer.read().split(b"\0") if value) + enabled = selected(paths) + print(f"ios_native={'true' if enabled else 'false'}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/sdk-swift-backend.sh b/scripts/ci/sdk-swift-backend.sh new file mode 100755 index 000000000..56a99bbeb --- /dev/null +++ b/scripts/ci/sdk-swift-backend.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Fresh hosted CI runner only. Local developer environments retain their own +# service/database lifecycle; use sdk-swift.sh integration against that endpoint. +set -euo pipefail +[[ "${GITHUB_ACTIONS:-}" == true ]] || { echo "This fixture bootstrap is for disposable CI runners only." >&2; exit 2; } +: "${RUNNER_TEMP:?A runner-owned temporary directory is required}" +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +state="$(mktemp -d "$RUNNER_TEMP/maple-swift-integration.XXXXXX")" +backend_pid="" +database_started=0 +cleanup() { + if [[ -n "$backend_pid" ]]; then + kill "$backend_pid" 2>/dev/null || true + wait "$backend_pid" 2>/dev/null || true + fi + if [[ "$database_started" == 1 ]]; then pg_ctl -D "$state/postgres" stop -m fast -w >/dev/null; fi +} +trap cleanup EXIT +trap 'exit 130' INT TERM + +# Values below are public, synthetic fixtures, as in sdk-integration.yml. +export APP_MODE=local +export DATABASE_URL=postgres://opensecret_user:password@127.0.0.1:35432/opensecret +export OPENSECRET_DEV_DATABASE_URL="$DATABASE_URL" +export OPENSECRET_DEV_POSTGRES=0 OPENSECRET_DEV_ENV=0 OPENSECRET_DEV_CONTAINERS=0 +export ENCLAVE_SECRET_MOCK=1111111111111111111111111111111111111111111111111111111111111111 +export JWT_SECRET=2222222222222222222222222222222222222222222222222222222222222222 +export OPENAI_API_BASE=http://127.0.0.1:9 +export TINFOIL_API_KEY=sdk-integration-placeholder +export OPENSECRET_BIND_ADDR=127.0.0.1:35433 +export RUST_LOG=opensecret=warn +export MAPLE_SWIFT_INTEGRATION_URL=http://127.0.0.1:35433 +export MAPLE_SWIFT_TEST_EMAIL=sdk-swift-ci-user@local.test +export MAPLE_SWIFT_TEST_PASSWORD=Maple-sdk-ci-test-1234 +export MAPLE_SWIFT_TEST_CLIENT_ID=ba5a14b5-d915-47b1-b7b1-afda52bc5fc6 +export MAPLE_SWIFT_TEST_INVITE_CODE=11111111-1111-4111-8111-111111111111 + +initdb -D "$state/postgres" --auth=trust --username=opensecret_user > "$state/initdb.log" +pg_ctl -D "$state/postgres" -l "$state/postgres.log" \ + -o "-h 127.0.0.1 -p 35432 -k $state" start -w >/dev/null +database_started=1 +createdb -h 127.0.0.1 -p 35432 -U opensecret_user opensecret +( + cd "$repo_root/services/opensecret" + diesel migration run + cargo build --locked +) +psql "$DATABASE_URL" -X -v ON_ERROR_STOP=1 -f "$repo_root/sdk/test/integration/bootstrap.sql" +"$repo_root/services/opensecret/target/debug/opensecret" > "$state/backend.log" 2>&1 & +backend_pid=$! +ready=0 +for _ in $(seq 1 60); do + if curl --fail --silent --show-error --max-time 2 "$MAPLE_SWIFT_INTEGRATION_URL/health-check" >/dev/null 2>&1; then + ready=1; break + fi + kill -0 "$backend_pid" 2>/dev/null || break + sleep 2 +done +[[ "$ready" == 1 ]] || { echo "Disposable OpenSecret failed to become ready; inspect the runner-local fixture logs." >&2; exit 1; } +# No provider-spending path, hosted trust opt-in, or secret-bearing log upload. +env -u RUN_LIVE_AI -u MAPLE_RUN_LIVE_ATTESTATION -u MAPLE_SWIFT_DEV_INTEGRATION \ + bash "$repo_root/scripts/ci/sdk-swift.sh" integration diff --git a/scripts/ci/sdk-swift.sh b/scripts/ci/sdk-swift.sh new file mode 100755 index 000000000..74acc180b --- /dev/null +++ b/scripts/ci/sdk-swift.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Swift SDK validation. Requires the host Xcode pin, also used by the Apple shell. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +package="$repo_root/sdk/swift" +mode="${1:-unit}" +case "$mode" in + unit|build|ios-build|format|live-attestation|integration) ;; + *) echo "Usage: sdk-swift.sh {unit|build|ios-build|format|live-attestation|integration}" >&2; exit 2 ;; +esac + +if [[ "$(uname -s)" != Darwin ]]; then + echo "The Swift SDK requires macOS and Xcode 26.5 (CryptoKit and Security.framework)." >&2 + exit 1 +fi +xcode_info="$(xcodebuild -version)" +xcode_version="$(awk '/^Xcode / { print $2; exit }' <<< "$xcode_info")" +case "$xcode_version" in + 26.5|26.5.*) ;; + *) echo "Select Xcode 26.5 before running Swift SDK checks." >&2; exit 1 ;; +esac + +# Pin formatting through Xcode, not a host-global swift-format installation. +swift_args=(--package-path "$package") +if [[ -n "${MAPLE_SWIFT_SCRATCH_PATH:-}" ]]; then + swift_args+=(--scratch-path "$MAPLE_SWIFT_SCRATCH_PATH") +fi +case "$mode" in + format) + xcrun swift-format format --configuration "$package/.swift-format" --in-place --recursive \ + "$package/Package.swift" "$package/Sources" "$package/Tests" + ;; + build) + xcrun swift build "${swift_args[@]}" -c release -Xswiftc -warnings-as-errors + ;; + ios-build) + xcrun swift build "${swift_args[@]}" -c release --triple arm64-apple-ios26.0-simulator \ + --sdk "$(xcrun --sdk iphonesimulator --show-sdk-path)" -Xswiftc -warnings-as-errors + xcrun swift build "${swift_args[@]}" -c release --triple arm64-apple-ios26.0 \ + --sdk "$(xcrun --sdk iphoneos --show-sdk-path)" -Xswiftc -warnings-as-errors + ;; + unit) + xcrun swift-format lint --configuration "$package/.swift-format" --strict --recursive \ + "$package/Package.swift" "$package/Sources" "$package/Tests" + # An inherited opt-in must never silently turn a pre-commit or PR unit gate + # into a hosted request or a disposable-account integration run. + env -u MAPLE_RUN_LIVE_ATTESTATION -u MAPLE_SWIFT_INTEGRATION_URL -u MAPLE_SWIFT_DEV_INTEGRATION \ + -u RUN_LIVE_AI \ + xcrun swift test "${swift_args[@]}" -Xswiftc -warnings-as-errors + ;; + live-attestation) + MAPLE_RUN_LIVE_ATTESTATION=1 xcrun swift test "${swift_args[@]}" --filter LiveAttestationTests + ;; + integration) + : "${MAPLE_SWIFT_INTEGRATION_URL:?Set MAPLE_SWIFT_INTEGRATION_URL to the disposable loopback backend}" + : "${MAPLE_SWIFT_TEST_EMAIL:?Set the disposable test account email}" + : "${MAPLE_SWIFT_TEST_PASSWORD:?Set the disposable test account password}" + : "${MAPLE_SWIFT_TEST_CLIENT_ID:?Set the disposable project client ID}" + python3 -I -c ' +import os, sys +from urllib.parse import urlsplit +try: + url = urlsplit(os.environ["MAPLE_SWIFT_INTEGRATION_URL"]) + valid = (url.scheme == "http" and url.hostname in {"127.0.0.1", "localhost", "::1"} + and url.port is not None and 0 < url.port <= 65535 and url.username is None + and url.password is None and not url.query and not url.fragment and url.path in {"", "/"}) +except ValueError: + valid = False +if not valid: + sys.exit("SDK integration requires an explicit HTTP loopback backend.") +' + xcrun swift test "${swift_args[@]}" --filter LocalIntegrationTests + ;; +esac diff --git a/scripts/ci/test_change_detection.py b/scripts/ci/test_change_detection.py index dfe52ec26..f3ebfe8bc 100644 --- a/scripts/ci/test_change_detection.py +++ b/scripts/ci/test_change_detection.py @@ -61,6 +61,19 @@ def test_auth_component_does_not_trigger_research_packaging(self) -> None: "frontend", ) + def test_native_ios_component_never_selects_research_packaging(self) -> None: + for path in ( + "apps/maple-ios/Sources/MapleAppUI/MapleRootView.swift", "apps/maple-ios/Package.swift", + "apps/maple-ios/Package.resolved", "apps/maple-ios/Maple.xcodeproj/project.pbxproj", + "apps/maple-ios/Config/PR.xcconfig", "apps/maple-ios/Resources/Info.plist", + "apps/maple-ios/scripts/build.sh", "apps/maple-ios/.githooks/pre-commit", + ".github/workflows/ios-native.yml", "scripts/ci/ios-native.sh", + "scripts/ci/ios_native_change_detection.py", + ): + with self.subTest(path=path): + self.assert_routes([path]) + self.assert_routes(["apps/maple-ios/Package.swift", "proxy/src/proxy.rs"], "macos", "linux", "windows") + def test_in_tree_rust_runtime_inputs_mark_desktop_lanes(self) -> None: for path in ( "proxy/Cargo.toml", @@ -91,6 +104,12 @@ def test_sdk_changes_do_not_select_app_lanes(self) -> None: "sdk/rust/src/client.rs", "sdk/rust/assets/aws_nitro_root.der", "sdk/rust/build.rs", + "sdk/swift/Package.swift", + "sdk/swift/Sources/MapleSDK/Trust/AttestationVerifier.swift", + "sdk/swift/Tests/MapleSDKTests/Trust/AttestationTests.swift", + "sdk/swift/.githooks/pre-commit", + ".github/workflows/sdk-swift.yml", + "scripts/ci/sdk-swift.sh", ): with self.subTest(path=path): self.assert_routes([path]) diff --git a/scripts/ci/test_hook_change_detection.py b/scripts/ci/test_hook_change_detection.py index 478b314b7..28280494e 100644 --- a/scripts/ci/test_hook_change_detection.py +++ b/scripts/ci/test_hook_change_detection.py @@ -71,6 +71,20 @@ def test_agent_component(self) -> None: with self.subTest(path=path): self.assert_selects([path], "agent") + def test_native_ios_is_an_independent_component(self) -> None: + for path in ( + "apps/maple-ios/Sources/MapleAppCore/AccountScope.swift", "apps/maple-ios/Package.swift", + "apps/maple-ios/Package.resolved", "apps/maple-ios/Maple.xcodeproj/project.pbxproj", + "apps/maple-ios/Config/PR.xcconfig", "apps/maple-ios/Resources/Info.plist", + "apps/maple-ios/Tests/MapleAppCoreTests/Account/AccountEntitlementsTests.swift", + ): + with self.subTest(path=path): + self.assert_selects([path], "ios_native") + self.assert_selects(["apps/maple-ios/README.md", "apps/maple-ios/AGENTS.md", "apps/maple-ios/.githooks/pre-commit"]) + self.assert_selects(["scripts/ci/ios-native.sh"], "ios_native", "repo") + self.assert_selects([".github/workflows/ios-native.yml"], "ios_native", "repo") + self.assert_selects(["apps/maple-ios/Package.swift", "sdk/swift/Sources/MapleSDK/MapleClient.swift"], "ios_native", "sdk_swift") + def test_sdk_components(self) -> None: self.assert_selects(["sdk/rust/src/client.rs"], "sdk_rust") self.assert_selects(["sdk/rust/tests/crypto.rs"], "sdk_rust") @@ -80,6 +94,20 @@ def test_sdk_components(self) -> None: self.assert_selects(["sdk/flake.lock"], "sdk_rust", "sdk_ts") self.assert_selects(["sdk/test/integration/bootstrap.sql"]) + def test_swift_sdk_is_independent_of_other_sdk_lanes(self) -> None: + for path in ( + "sdk/swift/Package.swift", "sdk/swift/.swift-format", + "sdk/swift/Sources/MapleSDK/Trust/AttestationVerifier.swift", + "sdk/swift/Tests/MapleSDKTests/Trust/AttestationTests.swift", + "sdk/swift/Sources/MapleSDK/Trust/Resources/aws_nitro_root.der", + ): + with self.subTest(path=path): + self.assert_selects([path], "sdk_swift") + self.assert_selects(["sdk/swift/README.md", "sdk/swift/AGENTS.md", "sdk/swift/.githooks/pre-commit"]) + self.assert_selects(["scripts/ci/sdk-swift.sh"], "sdk_swift", "repo") + self.assert_selects([".github/workflows/sdk-swift.yml"], "sdk_swift", "repo") + self.assert_selects(["sdk/swift/Package.swift", "sdk/rust/src/client.rs"], "sdk_swift", "sdk_rust") + def test_proxy_opensecret_and_updates(self) -> None: self.assert_selects(["proxy/src/main.rs"], "proxy") self.assert_selects(["proxy/Cargo.toml"], "proxy") diff --git a/scripts/ci/test_ios_native.py b/scripts/ci/test_ios_native.py new file mode 100644 index 000000000..fb21e7b9b --- /dev/null +++ b/scripts/ci/test_ios_native.py @@ -0,0 +1,177 @@ +"""Native iOS lane isolation, profile selection and unprivileged CI contracts.""" + +import functools +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +from ios_native_change_detection import selected + + +ROOT = Path(__file__).resolve().parents[2] + + +@functools.cache +def workflow(): + result = subprocess.run(["yq", "-o=json", ".", str(ROOT / ".github/workflows/ios-native.yml")], + check=True, capture_output=True, text=True) + return json.loads(result.stdout) + + +def strings(value): + if isinstance(value, str): + yield value + elif isinstance(value, dict): + for key, child in value.items(): + yield str(key) + yield from strings(child) + elif isinstance(value, list): + for child in value: + yield from strings(child) + + +class NativeSelectionTests(unittest.TestCase): + def test_app_and_consumed_build_inputs_select_native(self): + for path in ( + "apps/maple-ios/Package.swift", "apps/maple-ios/Package.resolved", + "apps/maple-ios/Sources/MapleAppCore/Chat/ChatRuntime.swift", + "apps/maple-ios/Maple.xcodeproj/project.pbxproj", "apps/maple-ios/Config/PR.xcconfig", + ".github/workflows/ios-native.yml", "scripts/ci/ios-native.sh", "flake.nix", "flake.lock", + "scripts/ci/ios_native_change_detection.py", "scripts/ci/hook_change_detection.py", + "apps/maple-agent/app/assets/fonts/Array-Regular.otf", + "apps/maple-research/frontend/public/fonts/Manrope-VariableFont_wght.ttf", + ): + with self.subTest(path=path): + self.assertTrue(selected([path])) + + def test_sdk_backend_other_apps_and_docs_do_not_package_native(self): + for path in ( + "sdk/swift/Package.swift", "sdk/swift/Sources/MapleSDK/MapleClient.swift", + "services/opensecret/src/web/responses/handlers.rs", "apps/maple-research/frontend/package.json", + "apps/maple-research/frontend/src/App.tsx", "apps/maple-agent/app/src/main.rs", + "apps/maple-auth/src/App.tsx", "apps/maple-ios/README.md", "apps/maple-ios/AGENTS.md", + "apps/maple-ios/.githooks/pre-commit", "docs/sdk-publishing.md", + ): + with self.subTest(path=path): + self.assertFalse(selected([path])) + self.assertFalse(selected([])) + self.assertTrue(selected(["sdk/swift/Package.swift", "apps/maple-ios/Package.swift"])) + + def test_unknown_root_and_invalid_paths_fail_open_to_validation(self): + for path in ("new-component/code.swift", "/absolute", "../outside", ""): + self.assertTrue(selected([path])) + + +class NativeWorkflowTests(unittest.TestCase): + def test_workflow_cannot_publish_sign_or_receive_credentials(self): + config = workflow() + self.assertEqual(config["permissions"], {"contents": "read"}) + self.assertEqual(set(config["on"]), {"push", "pull_request", "workflow_dispatch"}) + self.assertEqual(config["on"]["push"]["branches"], ["master"]) + self.assertEqual(config["on"]["pull_request"]["branches"], ["master"]) + self.assertEqual(config["concurrency"]["group"], "ios-native-ci-${{ github.ref }}") + for text in strings(config): + self.assertNotRegex(text, r"\bsecrets\b|github\.token|\bGH_TOKEN\b|id-token|git (?:tag|push)|gh release|fastlane|notarytool|altool|upload-artifact|CODE_SIGN_IDENTITY") + for job in config["jobs"].values(): + self.assertNotIn("environment", job) + self.assertNotIn("permissions", job) + for step in job["steps"]: + self.assertNotIn("${{", step.get("run", "")) + action = step.get("uses", "") + if action: + self.assertRegex(action, r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+@[0-9a-f]{40}$") + if action.startswith("actions/checkout@"): + self.assertIs(step["with"]["persist-credentials"], False) + + def test_failed_selection_still_runs_unsigned_validation(self): + job = workflow()["jobs"]["ios-native"] + self.assertIn("always() && !cancelled()", job["if"]) + self.assertIn("needs.changes.result != 'success'", job["if"]) + self.assertIn("needs.changes.outputs.ios_native != 'false'", job["if"]) + self.assertEqual(job["runs-on"], "macos-26-xlarge") + commands = "\n".join(step.get("run", "") for step in job["steps"]) + self.assertIn("/Applications/Xcode_26.5.app", commands) + for mode in ("unit", "cross-build", "simulator-build", "device-build"): + self.assertIn(".#apple -c bash scripts/ci/ios-native.sh " + mode, commands) + self.assertNotIn("release", commands) + self.assertNotIn("integration", commands) + + +class NativeRecipeTests(unittest.TestCase): + def run_recipe(self, mode, extra=None): + with tempfile.TemporaryDirectory() as folder: + temporary = Path(folder) + trace = temporary / "trace" + environment_trace = ('printf "account=%s auth=%s sdk=%s live=%s provider=%s vite=%s profile=%s api=%s ld=%s\\n" ' + '"${MAPLE_IOS_ACCOUNT_LIVE:-}" "${MAPLE_IOS_AUTH_LIVE:-}" ' + '"${MAPLE_SWIFT_INTEGRATION_URL:-}" "${MAPLE_RUN_LIVE_ATTESTATION:-}" ' + '"${RUN_LIVE_AI:-}" "${VITE_OPEN_SECRET_API_URL:-}" ' + '"${MAPLE_PROFILE:-}" "${MAPLE_API_URL:-}" "${LD:-}" >> "$TRACE"\n') + for name, source in { + "uname": '#!/bin/sh\necho "${FAKE_SYSTEM:-Darwin}"\n', + "xcodebuild": ('#!/bin/sh\nif [ "$1" = -version ]; then echo "Xcode ${FAKE_XCODE:-26.5}"; exit 0; fi\n' + 'printf "%s\\n" "$*" >> "$TRACE"\n' + environment_trace), + "xcrun": ('#!/bin/sh\nprintf "%s\\n" "$*" >> "$TRACE"\n' + environment_trace + + 'if [ "$1" = --sdk ]; then echo /synthetic-sdk; fi\n'), + }.items(): + path = temporary / name + path.write_text(source) + path.chmod(0o755) + env = {**os.environ, "PATH": str(temporary) + os.pathsep + os.environ.get("PATH", ""), "TRACE": str(trace)} + env.update(extra or {}) + result = subprocess.run(["bash", str(ROOT / "scripts/ci/ios-native.sh"), mode], + env=env, capture_output=True, text=True) + return result, trace.read_text() if trace.exists() else "" + + def test_unit_never_inherits_live_opt_ins_or_linker_overrides(self): + extra = {name: "synthetic-untrusted-value" for name in ( + "MAPLE_IOS_ACCOUNT_LIVE", "MAPLE_IOS_AUTH_LIVE", "MAPLE_SWIFT_INTEGRATION_URL", + "MAPLE_RUN_LIVE_ATTESTATION", "RUN_LIVE_AI", "VITE_OPEN_SECRET_API_URL", + "MAPLE_PROFILE", "MAPLE_API_URL", "LD", "SDKROOT", + )} + result, trace = self.run_recipe("unit", extra) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("swift-format lint", trace) + swift_test = next(line for line in trace.splitlines() if line.startswith("swift test ")) + self.assertIn("--sdk macosx --show-sdk-path", trace) + self.assertIn("--sdk /synthetic-sdk", swift_test) + self.assertNotIn("synthetic-untrusted-value", trace) + self.assertNotIn("xcodebuild", trace) + + def test_packaging_always_selects_pr_and_disables_signing(self): + for mode, destination in (("simulator-build", "generic/platform=iOS Simulator"), ("device-build", "generic/platform=iOS")): + with self.subTest(mode=mode): + result, trace = self.run_recipe(mode, {"MAPLE_PROFILE": "local", "MAPLE_API_URL": "http://127.0.0.1:9999"}) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("-configuration PR", trace) + self.assertIn("-destination " + destination, trace) + self.assertIn("CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO build", trace) + self.assertNotIn("127.0.0.1:9999", trace) + self.assertNotIn("archive", trace) + + def test_cross_build_compiles_both_ios_platforms_with_warning_errors(self): + result, trace = self.run_recipe("cross-build") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("--triple arm64-apple-ios26.0-simulator", trace) + self.assertIn("--triple arm64-apple-ios26.0 ", trace) + self.assertIn("-Xswiftc -warnings-as-errors", trace) + + def test_unrecognized_mode_or_toolchain_fails_before_build(self): + for mode, extra in (("release", {}), ("unit", {"FAKE_SYSTEM": "Linux"}), ("unit", {"FAKE_XCODE": "26.4"})): + result, trace = self.run_recipe(mode, extra) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(trace, "") + + def test_pr_configuration_cannot_include_local_overrides(self): + profile = (ROOT / "apps/maple-ios/Config/PR.xcconfig").read_text() + self.assertNotIn("Local", profile) + self.assertIn('MAPLE_PROFILE = development', profile) + base = (ROOT / "apps/maple-ios/Config/Base.xcconfig").read_text() + self.assertIn('PRODUCT_BUNDLE_IDENTIFIER = cloud.opensecret.maple.native.dev', base) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/ci/test_sdk_swift.py b/scripts/ci/test_sdk_swift.py new file mode 100644 index 000000000..b179c3b22 --- /dev/null +++ b/scripts/ci/test_sdk_swift.py @@ -0,0 +1,128 @@ +"""Swift SDK build, CI and local-service isolation boundaries.""" + +import functools +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] + + +@functools.cache +def workflow(): + result = subprocess.run( + ["yq", "-o=json", ".", str(ROOT / ".github/workflows/sdk-swift.yml")], + check=True, capture_output=True, text=True, + ) + return json.loads(result.stdout) + + +def strings(value): + if isinstance(value, str): + yield value + elif isinstance(value, dict): + for key, child in value.items(): + yield str(key) + yield from strings(child) + elif isinstance(value, list): + for child in value: + yield from strings(child) + + +class SwiftSDKWorkflowTests(unittest.TestCase): + def test_ci_cannot_publish_or_receive_privileged_credentials(self): + config = workflow() + self.assertEqual(config["permissions"], {"contents": "read"}) + self.assertEqual(set(config["on"]), {"push", "pull_request", "workflow_dispatch"}) + self.assertEqual(config["on"]["push"]["branches"], ["master"]) + self.assertEqual(config["on"]["pull_request"]["branches"], ["master"]) + self.assertEqual(config["concurrency"]["group"], "sdk-swift-ci-${{ github.ref }}") + for text in strings(config): + self.assertNotRegex(text, r"\bsecrets\b|github\.token|\bGH_TOKEN\b|id-token|git (?:tag|push)|gh release|fastlane|notarytool|altool") + for job in config["jobs"].values(): + self.assertNotIn("environment", job) + self.assertNotIn("permissions", job) + for step in job["steps"]: + self.assertNotIn("${{", step.get("run", "")) + action = step.get("uses", "") + if action: + self.assertRegex(action, r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+@[0-9a-f]{40}$") + if action.startswith("actions/checkout@"): + self.assertIs(step["with"]["persist-credentials"], False) + + def test_missing_change_selection_fails_open_to_validation(self): + condition = workflow()["jobs"]["sdk-swift"]["if"] + self.assertIn("always() && !cancelled()", condition) + self.assertIn("needs.changes.result != 'success'", condition) + self.assertIn("needs.changes.outputs.sdk_swift != 'false'", condition) + + def test_lane_builds_and_tests_package_and_local_backend_with_pinned_tools(self): + job = workflow()["jobs"]["sdk-swift"] + self.assertEqual(job["runs-on"], "macos-26-xlarge") + self.assertEqual(job["env"]["OPENSECRET_DEV_ENV"], "0") + self.assertEqual(job["env"]["OPENSECRET_DEV_POSTGRES"], "0") + commands = "\n".join(step.get("run", "") for step in job["steps"]) + self.assertIn("/Applications/Xcode_26.5.app", commands) + self.assertIn(".#apple -c bash scripts/ci/sdk-swift.sh unit", commands) + self.assertIn(".#apple -c bash scripts/ci/sdk-swift.sh build", commands) + self.assertIn(".#apple -c bash scripts/ci/sdk-swift.sh ios-build", commands) + self.assertIn("'./services/opensecret?submodules=1' -c bash scripts/ci/sdk-swift-backend.sh", commands) + self.assertNotIn("live-attestation", commands) + + +class SwiftSDKScriptTests(unittest.TestCase): + def run_script(self, mode, extra=None): + with tempfile.TemporaryDirectory() as folder: + temporary = Path(folder) + for name, source in { + "uname": "#!/bin/sh\necho Darwin\n", + "xcodebuild": "#!/bin/sh\necho 'Xcode 26.5'\n", + "xcrun": "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$TRACE\"\nprintf 'integration=%s dev=%s live=%s provider=%s\\n' \"${MAPLE_SWIFT_INTEGRATION_URL:-}\" \"${MAPLE_SWIFT_DEV_INTEGRATION:-}\" \"${MAPLE_RUN_LIVE_ATTESTATION:-}\" \"${RUN_LIVE_AI:-}\" >> \"$TRACE\"\n", + }.items(): + executable = temporary / name + executable.write_text(source) + executable.chmod(0o755) + env = {key: value for key, value in os.environ.items() if not key.startswith("MAPLE_SWIFT_")} + env.update({"PATH": str(temporary) + os.pathsep + env.get("PATH", ""), "TRACE": str(temporary / "trace")}) + env.update(extra or {}) + result = subprocess.run(["bash", str(ROOT / "scripts/ci/sdk-swift.sh"), mode], env=env, capture_output=True, text=True) + trace = (temporary / "trace").read_text() if (temporary / "trace").exists() else "" + return result, trace + + def test_unit_scrubs_all_network_opt_ins_before_swift_test(self): + result, trace = self.run_script("unit", {"MAPLE_SWIFT_INTEGRATION_URL": "http://localhost:3000", + "MAPLE_SWIFT_DEV_INTEGRATION": "1", "MAPLE_RUN_LIVE_ATTESTATION": "1", "RUN_LIVE_AI": "1"}) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("swift-format lint", trace) + self.assertIn("swift test", trace) + self.assertEqual(trace.splitlines()[-1], "integration= dev= live= provider=") + + def test_integration_requires_explicit_fixture_configuration(self): + result, trace = self.run_script("integration") + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("swift test", trace) + env = {"MAPLE_SWIFT_TEST_EMAIL": "synthetic@local.test", "MAPLE_SWIFT_TEST_PASSWORD": "synthetic", + "MAPLE_SWIFT_TEST_CLIENT_ID": "synthetic"} + for endpoint in ("https://example.test", "http://localhost.evil.test:3000", "http://localhost:3000@evil.test", + "http://127.0.0.1:3000/?x=1", "http://user@127.0.0.1:3000", "http://127.0.0.1:99999"): + with self.subTest(endpoint=endpoint): + result, trace = self.run_script("integration", {**env, "MAPLE_SWIFT_INTEGRATION_URL": endpoint}) + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("swift test", trace) + result, trace = self.run_script("integration", {**env, "MAPLE_SWIFT_INTEGRATION_URL": "http://127.0.0.1:3000"}) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("--filter LocalIntegrationTests", trace) + + def test_fixture_bootstrap_refuses_developer_workspaces(self): + env = {**os.environ, "GITHUB_ACTIONS": "false"} + result = subprocess.run(["bash", str(ROOT / "scripts/ci/sdk-swift-backend.sh")], env=env, capture_output=True, text=True) + self.assertEqual(result.returncode, 2) + self.assertIn("disposable CI runners only", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/sdk/README.md b/sdk/README.md index a440b633e..5d297e291 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -1,7 +1,7 @@ # Maple SDKs -This directory contains the TypeScript/React and Rust clients used by Maple and -internal OpenSecret applications. Both clients establish attested, +This directory contains the TypeScript/React, Rust and Swift clients used by Maple and +internal OpenSecret applications. The clients establish attested, end-to-end encrypted sessions with an OpenSecret backend and expose the API surface needed by those applications. @@ -16,9 +16,11 @@ and tests. client, attestation policy, model/conversation APIs, and internal developer platform client. - `rust/` — the `maple-sdk` crate, imported as `maple_sdk` by native clients. +- `swift/` — the independent `MapleSDK` Swift package for native Apple clients; + see its [guide](swift/AGENTS.md) and [build/transport documentation](swift/README.md). - `docs/PLATFORM.md` — internal developer/platform API notes. - repository-root `.github/workflows/sdk-*.yml` — path-scoped TypeScript, Rust, - and supply-chain validation for this directory. + Swift, integration and supply-chain validation for this directory. Maple consumers prefer independently selected published SDK versions; local TypeScript `file:` and Rust `path` dependencies remain available for active diff --git a/sdk/swift/.githooks/pre-commit b/sdk/swift/.githooks/pre-commit new file mode 100755 index 000000000..7b27a38bc --- /dev/null +++ b/sdk/swift/.githooks/pre-commit @@ -0,0 +1,7 @@ +#!/bin/sh +# The orchestrator enters the root Apple shell. Xcode supplies Swift and its +# formatter; this hook neither starts services nor runs hosted checks. +set -u +. "${MAPLE_HOOK_REPO_ROOT:?}/.githooks/lib/common.sh" +hook_require xcrun +hook_run bash "$MAPLE_HOOK_REPO_ROOT/scripts/ci/sdk-swift.sh" unit diff --git a/sdk/swift/.gitignore b/sdk/swift/.gitignore new file mode 100644 index 000000000..2d9f16e2d --- /dev/null +++ b/sdk/swift/.gitignore @@ -0,0 +1,2 @@ +.build/ +.swiftpm/ diff --git a/sdk/swift/.swift-format b/sdk/swift/.swift-format new file mode 100644 index 000000000..4fa879e53 --- /dev/null +++ b/sdk/swift/.swift-format @@ -0,0 +1,9 @@ +{ + "version": 1, + "lineLength": 120, + "indentation": { "spaces": 4 }, + "rules": { + "AlwaysUseLowerCamelCase": false, + "UseTripleSlashForDocumentationComments": true + } +} diff --git a/sdk/swift/AGENTS.md b/sdk/swift/AGENTS.md new file mode 100644 index 000000000..a864ee0a5 --- /dev/null +++ b/sdk/swift/AGENTS.md @@ -0,0 +1,56 @@ +# Swift SDK contributor guide + +Read the repository-root guide, `sdk/README.md`, this package's `README.md`, +and the `develop-opensecret-sdk` skill before changing the Swift SDK. + +Keep this package independent of SwiftUI and application workflow state. +`API/` owns typed Research-used contracts; `Transport/` owns Transport V2 +framing, cryptography and native handoff; `Credentials/` owns atomic durable +credential authority; `Trust/` owns Nitro and PCR policy. Compare changed +contracts with both `sdk/src/lib` and `sdk/rust/src`, and inspect the in-tree +OpenSecret implementation when wire or authentication behavior changes. +Record intentional reference differences in task evidence. + +Preserve exact HTTPS/loopback enforcement, pinned Nitro/PCR trust, handshake +key binding, non-contributory-key rejection, authenticated stream framing, +bounded recovery, and native handoff one-use ownership. Never turn malformed +attestation into a local mock or replace encrypted requests with raw HTTP. +Keep errors and logs free of credential-bearing URLs, tokens, session keys, +raw attestation, prompts, replies, audio and decrypted responses. + +`MapleClient` instances using `KeychainSecureStore` share a process-wide +`CredentialAuthority` keyed by Keychain service and canonical API origin. +Transports additionally key their sharing by exact endpoint and complete PCR +policy; refresh coalescing stays in the authority across those transports. +Preserve this registry and its synchronous construction lock. Other custom +stores intentionally create independent clients; callers must reuse one +client, or explicitly share an authority when constructing low-level +transports over the same credentials. No process-local actor coordinates +separate app processes or extensions. Preserve revision compare-and-swap, +generation fencing, atomic access/refresh pairs, principal consistency and +fail-closed persistence. Cancellation does not replace ownership checks. +Device-only Keychain storage has no plaintext fallback; physical-device +behavior remains a separate check. + +Use Xcode 26.5 and the root `.#apple` shell. The checked-in `.swift-format` +configuration and Xcode's formatter are the source of formatting policy. +Run `scripts/ci/sdk-swift.sh unit`, `build` and `ios-build` from the root; use focused +Swift tests while iterating. Run `integration` against an explicitly +configured disposable loopback service for affected API/protocol changes, +and `live-attestation` for real development-enclave trust proof when that +environment is authorized. Never run the CI-only backend bootstrap against +a developer-managed workspace. Never represent mocks or compilation as live +Nitro, hosted OAuth, native Apple acquisition or physical-device evidence. + +Changing the workflow, classifier or CI scripts also requires their Python +regression tests and root `nix flake check --no-update-lock-file`. The SDK's +lane and hook stay independent of Research packaging. New tests belong to +the relevant boundary and should exercise failure, cancellation or ownership +behavior rather than only encoding the implementation's output. + +Keep the Swift version in `VERSION` independent of app versions. Local +packages are supported during development. Package publication, checkpoint +tags, remote distribution, signing and app releases require their own +authorization; ordinary checks create none of them. Keep public guidance +self-contained and keep operational context and live captures outside this +repository. diff --git a/sdk/swift/Package.swift b/sdk/swift/Package.swift new file mode 100644 index 000000000..d8cf368fb --- /dev/null +++ b/sdk/swift/Package.swift @@ -0,0 +1,12 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "MapleSDK", + platforms: [.iOS("26.0"), .macOS(.v15)], + products: [.library(name: "MapleSDK", targets: ["MapleSDK"])], + targets: [ + .target(name: "MapleSDK", resources: [.process("Trust/Resources")]), + .testTarget(name: "MapleSDKTests", dependencies: ["MapleSDK"], resources: [.process("Trust/Fixtures")]), + ] +) diff --git a/sdk/swift/README.md b/sdk/swift/README.md new file mode 100644 index 000000000..18d96fb74 --- /dev/null +++ b/sdk/swift/README.md @@ -0,0 +1,176 @@ +# Maple Swift SDK + +`MapleSDK` is a Swift 6 package for iOS 26 and macOS 15 or later. It uses +CryptoKit, Security.framework, URLSession and Swift concurrency. The package +has no external dependencies and no UI dependency. It implements Transport V2 +and the authentication, account, Responses, conversations, projects, models, +instructions and audio contracts used by Maple Research. + +## Development + +Select Xcode 26.5. Its bundled `swift-format` supplies the formatter; use the +root Apple Nix shell for the surrounding pinned command-line environment: + +```sh +nix develop --no-update-lock-file .#apple -c bash scripts/ci/sdk-swift.sh unit +nix develop --no-update-lock-file .#apple -c bash scripts/ci/sdk-swift.sh build +nix develop --no-update-lock-file .#apple -c bash scripts/ci/sdk-swift.sh ios-build +``` + +Run these from the repository root. `unit` checks formatting and runs +deterministic tests with hosted/local integration opt-ins cleared. `build` +builds the release package; `ios-build` compiles the library for arm64 iOS 26 +and its simulator without signing. All compile with warnings treated as errors. +Use `bash scripts/ci/sdk-swift.sh format` to apply the checked-in formatting +policy. Set `MAPLE_SWIFT_SCRATCH_PATH` to isolate build products when running +concurrent checks. Tests use the real historical Nitro document shared with +the TypeScript and Rust implementations, synthetic protocol fixtures, and +in-memory credential stores; deterministic success does not establish live +deployment behavior or physical-device Keychain behavior. + +An explicitly authorized development-enclave trust check is separate: + +```sh +nix develop --no-update-lock-file .#apple -c bash scripts/ci/sdk-swift.sh live-attestation +``` + +It creates an anonymous session, verifies Nitro and development PCR policy, +fetches the signed PCR history, and checks clock rejection. It does not sign +in, invoke a model, or save live captures. The full encrypted transport's +development checks use `MAPLE_SWIFT_DEV_INTEGRATION=1` and remain opt-in. + +For a disposable, already-running local OpenSecret backend, set +`MAPLE_SWIFT_INTEGRATION_URL` to its exact HTTP loopback URL with a port and +set `MAPLE_SWIFT_TEST_EMAIL`, `MAPLE_SWIFT_TEST_PASSWORD`, and +`MAPLE_SWIFT_TEST_CLIENT_ID`. The optional `MAPLE_SWIFT_TEST_INVITE_CODE` +supports disposable account creation. Then run: + +```sh +nix develop --no-update-lock-file .#apple -c bash scripts/ci/sdk-swift.sh integration +``` + +These tests can mutate the named disposable account. Use the owning local +environment's service lifecycle; the check script never starts or stops it. +On macOS, `MAPLE_SWIFT_MAIL_FIXTURE_HELPER` can name an absolute executable +that returns a code actually delivered by that disposable backend. It receives +the ceremony (`verification`, `password-reset` or `account-deletion`) and test +email as two arguments, then returns only the code on standard output. The +mail-ceremony case is skipped when the helper is absent; stored code hashes +are not a substitute for delivered-mail evidence. +The separate CI-only fixture bootstrap creates its own PostgreSQL directory, +migrates the in-tree backend, runs the local integration suite, and stops +only its own processes. Provider-spending cases require the separate +`RUN_LIVE_AI=1` opt-in and explicitly configured development provider access. +Local mock attestation establishes protocol behavior, not Nitro trust. + +## Using the package + +During monorepo development, add `sdk/swift` as a local Swift package +dependency and link its `MapleSDK` product. Keep a `MapleClient` for the +application's lifetime. Clients using the default Keychain store share a +process-wide credential authority for each Keychain service and canonical API +origin. They also share an encrypted transport when their exact endpoint and +complete trust policy match: + +```swift +let origin = "https://api.example.com" +let client = try MapleClient( + apiURL: origin, + clientID: "00000000-0000-0000-0000-000000000000", + policy: PCRTrustPolicy(environment: .production) +) +// After sign-in, capture once for this account session: +let api = try await client.accountAPI() +``` + +Observe `await client.credentials.changes()` to retire application-owned work +when authentication is replaced or revoked. This bounded stream starts with the +current token-free generation and cleanup state; routine token refresh emits no +change. Recheck `currentState()` and the current identity before handling a delayed +event. A failed Keychain clear still revokes in-memory authority and emits a cleanup +requirement. `completePendingCleanup()` retries that revoked state without clearing +any subsequently installed credentials. Keep replacement app sessions blocked until +private app cleanup also succeeds; an authority observer must not call unconditional +sign-out on a replacement account. + +Credential authorities have monotonic revisions and account generations. +Capture a snapshot before authentication, then install the complete returned +access/refresh pair against that snapshot before publishing the account. +`MapleClient` handles ordinary sign-in installation. Use its `accountAPI()` +after sign-in to capture both principal and immutable account generation; +principal identity alone cannot fence same-account logout and sign-in. +Refresh coalescing belongs to the shared authority, including when transports +use different endpoint paths or trust policies. Custom `KeychainSecureStore` +service names isolate credential namespaces. Other custom stores create +independent clients; reuse one client when they access the same credentials. +Advanced callers constructing transports directly must explicitly share one +`CredentialAuthority` for each credential namespace and origin. These +process-local ownership checks do not coordinate separate app processes or +extensions. JWT claims are unverified ownership/expiry hints; backend signature +validation and authorization remain authoritative. + +The default store uses non-synchronizing, device-only Keychain entries. +Credentials and attested sessions are never stored in preferences. Session +cache identity includes the exact endpoint and complete PCR policy. Account +owners must cancel their work on logout and clear authority even if remote +revocation fails. A failed durable credential cleanup blocks continued use. +Cancel the task consuming a response to cancel its stream work. Typed stream +termination cancels its producer, and credential generations reject stale +response reads. Applications still own their operation lifecycle. + +## Trust and transport + +The public attestation verifier has no mock switch. It verifies the +byte-exact nonce, COSE ES384 signature and complete X.509 path against only +the bundled AWS Nitro root; system trust anchors and network chain fetching +are disabled. Security.framework validates path constraints at the signed +document timestamp, and every certificate is additionally checked against +the device clock, with 300 seconds of `notBefore` leeway and strict +`notAfter`. The signed timestamp explains clock failures and cannot replace +the device clock. `AttestationClockSkewError` preserves actionable clock +guidance; other errors contain bounded categories, not decrypted content. + +`PCRTrustPolicy` defaults to production and never crosses environment trust +roots. Unknown PCR0 requires a valid signature under the pinned OpenSecret +P-384 history key. History responses have a five-second deadline, 1 MiB and +2,048-entry limits, and cannot redirect. Only PCR0 is signed in the existing +history protocol; timestamp, PCR1 and PCR2 are not authenticated freshness or +identity assertions. Additional roots and URLs are explicit configuration. + +Only an exact HTTP loopback transport endpoint can select the internal local +mock parser. HTTPS endpoints always require real Nitro verification and PCR +trust before deriving session traffic keys. The handshake also binds the +client public key, server X25519 key and computed session ID. No V1 fallback +exists. Authenticated sequence, size and terminal checks reject tampering, +truncation and data after the terminal record. + +The single resend rule follows the other SDKs: only an outer HTTP 400 with +the exact versioned `session_not_found` or `request_decryption_failed` +contract can establish a new verified session and resend once. Network +failure, application errors, decryption failures and partial streams never +trigger replay. The outer hint is unauthenticated and cannot guarantee +cross-session at-most-once execution; application idempotency remains +necessary where required. Native handoff and OAuth grants retain their +original session and credential ownership fences. + +Public request header names are normalized to lowercase before envelope +validation. Case-colliding dictionary keys are rejected, including duplicate +inference-budget controls; the internal wire format requires lowercase names. +The inference send budget remains local and is never forwarded as a header. + +## Version and distribution + +`VERSION` is the independent Swift SDK version; it does not track Maple's +application version or the TypeScript/Rust package versions. Reserve +`maple-sdk-swift/vX.Y.Z` for Swift SDK source checkpoint tags. No tag or release +is created by development or CI. These namespaced monorepo tags are not +directly consumable SwiftPM semantic versions: SwiftPM requires a manifest +at the package repository root. Before a separately authorized publication, +prepare a package-root distribution or registry archive from `sdk/swift`, +with its own semantic version and immutable source provenance. Until then, +consumers use the local package and record the Maple revision. + +Do not create a Maple app release or move the GitHub latest-release pointer +to publish this SDK. Follow the shared [consumer policy](../../docs/sdk-publishing.md#consumer-version-policy). +SDK publication, consumer pin updates, app signing and store submission are +separate actions. diff --git a/sdk/swift/Sources/MapleSDK/API/MapleTransport.swift b/sdk/swift/Sources/MapleSDK/API/MapleTransport.swift new file mode 100644 index 000000000..394e4b53e --- /dev/null +++ b/sdk/swift/Sources/MapleSDK/API/MapleTransport.swift @@ -0,0 +1,204 @@ +import Foundation + +/// A logical HTTP request. Implementations must attest and encrypt before dispatch; +/// this protocol is not permission to use a plaintext HTTP adapter in production. +public protocol MapleTransport: Sendable { + func send(_ request: MapleRequest) async throws -> MapleResponse +} + +public enum MapleHTTPMethod: String, Sendable, Codable { + case get = "GET", post = "POST", delete = "DELETE" +} + +public enum MapleRequestAuthentication: Sendable, Equatable { + case none + case required +} + +public struct MapleRequest: Sendable { + public var method: MapleHTTPMethod + public var target: String + public var headers: [String: String] + public var body: Data? + public var auth: MapleRequestAuthentication + /// Capture the owning account at the caller. The transport must reject a + /// different account before dispatch and while consuming the response. + public var expectedPrincipalId: String? + /// Includes an eligible Transport V2 recovery send; never forwarded upstream. + public var maximumInferenceSends: Int + public var allowSessionRecovery: Bool + + public init( + method: MapleHTTPMethod, target: String, headers: [String: String] = [:], + body: Data? = nil, auth: MapleRequestAuthentication = .required, + expectedPrincipalId: String? = nil, maximumInferenceSends: Int = 2, + allowSessionRecovery: Bool = true + ) { + self.method = method + self.target = target + self.headers = headers + self.body = body + self.auth = auth + self.expectedPrincipalId = expectedPrincipalId + self.maximumInferenceSends = maximumInferenceSends + self.allowSessionRecovery = allowSessionRecovery + } +} + +public struct MapleResponse: Sendable { + public let status: Int + public let headers: [String: String] + public let body: AsyncThrowingStream + public let inferenceSendCount: Int + + public init( + status: Int, headers: [String: String] = [:], + body: AsyncThrowingStream, inferenceSendCount: Int = 1 + ) { + self.status = status + self.headers = headers + self.body = body + self.inferenceSendCount = inferenceSendCount + } + + public func header(_ name: String) -> String? { + headers.first { $0.key.caseInsensitiveCompare(name) == .orderedSame }?.value + } +} + +/// Errors deliberately exclude untrusted response bodies, tokens, and URLs. +public enum ResearchAPIError: Error, Sendable, Equatable, LocalizedError { + case invalidArgument + case invalidResponse + case responseTooLarge + case http(status: Int, reason: RejectionReason) + case truncatedStream + case invalidStream + case invalidCallback + case expiredAuthenticationAttempt + + public enum RejectionReason: String, Sendable, Codable { + case unspecified, contextLimit, freeTierTokenLimit, usageLimit + case imageDescriptionUnavailable, authenticationRequired, forbidden, notFound + case invalidLogin, emailAlreadyRegistered, oauthNotLinked, invalidInviteCode + case userNotFound, oauthEmailUnavailable, alreadyVerified, modelUnavailable + case billingCleanupUnavailable, payloadTooLarge, rateLimited, unsupportedValue + } + + public var errorDescription: String? { + switch self { + case .invalidArgument: "The request is invalid." + case .invalidResponse: "The service returned an invalid response." + case .responseTooLarge: "The service response exceeded the supported size." + case .http: "The service could not complete the request." + case .truncatedStream: "The response was interrupted before its final status arrived." + case .invalidStream: "The service returned an invalid response stream." + case .invalidCallback: "The authentication callback is invalid. Restart sign-in." + case .expiredAuthenticationAttempt: "Sign-in expired. Please try again." + } + } +} + +public let openSecretInferenceSendLimitHeader = "x-opensecret-client-inference-send-limit" + +public struct OpenSecretInferenceCapacityError: Error, Sendable, Equatable, LocalizedError { + public let status: Int + public let retryDelayMilliseconds: Int? + public let inferenceSendCount: Int + public var errorDescription: String? { "Inference capacity is temporarily unavailable." } + + public init?(response: MapleResponse) { + guard [429, 503].contains(response.status), + response.header("x-opensecret-error-contract") == "1", + response.header("x-opensecret-error-code") == "inference_capacity", + response.header("x-opensecret-client-replay") == "safe" + else { return nil } + status = response.status + inferenceSendCount = response.inferenceSendCount + let value = response.header("retry-after") + if let value, !value.isEmpty, + value.utf8.allSatisfy({ (48...57).contains($0) }), + (value == "0" || !value.hasPrefix("0")) + { + if let seconds = Int(value), seconds <= 60 { + retryDelayMilliseconds = seconds * 1_000 + } else { + retryDelayMilliseconds = nil + } + } else { + retryDelayMilliseconds = 1_000 + } + } +} + +/// Used only for extensible metadata, tool arguments, and future event payloads. +public enum JSONValue: Codable, Sendable, Equatable { + case null, bool(Bool), integer(Int64), number(Double), string(String) + case array([JSONValue]), object([String: JSONValue]) + + public init(from decoder: any Decoder) throws { + let value = try decoder.singleValueContainer() + if value.decodeNil() { + self = .null + } else if let decoded = try? value.decode(Bool.self) { + self = .bool(decoded) + } else if let decoded = try? value.decode(Int64.self) { + self = .integer(decoded) + } else if let decoded = try? value.decode(Double.self) { + self = .number(decoded) + } else if let decoded = try? value.decode(String.self) { + self = .string(decoded) + } else if let decoded = try? value.decode([JSONValue].self) { + self = .array(decoded) + } else { + self = .object(try value.decode([String: JSONValue].self)) + } + } + + public func encode(to encoder: any Encoder) throws { + var value = encoder.singleValueContainer() + switch self { + case .null: try value.encodeNil() + case let .bool(v): try value.encode(v) + case let .integer(v): try value.encode(v) + case let .number(v): try value.encode(v) + case let .string(v): try value.encode(v) + case let .array(v): try value.encode(v) + case let .object(v): try value.encode(v) + } + } +} + +/// Update fields distinguish omission (unchanged) from null (clear). +public enum NullableUpdate: Sendable { + case unchanged, clear, set(Value) +} + +internal enum APIJSON { + static func decoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return decoder + } + + static func encode(_ value: T) throws -> Data { + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + return try encoder.encode(value) + } +} + +/// Finds the capacity contract through Foundation error wrappers without exposing +/// their messages. The depth bound also contains cyclic wrapper chains. +public func findOpenSecretInferenceCapacityError(_ error: any Error) -> OpenSecretInferenceCapacityError? { + var current: (any Error)? = error + var visited: Set = [] + for _ in 0..<8 { + guard let value = current else { return nil } + if let capacity = value as? OpenSecretInferenceCapacityError { return capacity } + let wrapped = value as NSError + guard visited.insert(ObjectIdentifier(wrapped)).inserted else { return nil } + current = wrapped.userInfo[NSUnderlyingErrorKey] as? any Error + } + return nil +} diff --git a/sdk/swift/Sources/MapleSDK/API/OAuthAPI.swift b/sdk/swift/Sources/MapleSDK/API/OAuthAPI.swift new file mode 100644 index 000000000..bfb4fde7f --- /dev/null +++ b/sdk/swift/Sources/MapleSDK/API/OAuthAPI.swift @@ -0,0 +1,185 @@ +import Foundation + +public enum OAuthProvider: String, Codable, Sendable { case google, github, apple } + +/// Only the transport can create handles; the retained session and anonymous +/// authority stay in the transport. A copied handle never permits a second use. +public struct OAuthSessionBinding: Sendable, Hashable { + public let id: UUID + internal init(id: UUID = UUID()) { self.id = id } +} + +public protocol OAuthSessionTransport: MapleTransport { + func beginOAuth(_ request: MapleRequest) async throws -> (MapleResponse, OAuthSessionBinding) + func completeOAuth(_ request: MapleRequest, binding: OAuthSessionBinding) async throws -> MapleResponse + func cancelOAuth(binding: OAuthSessionBinding) async +} + +/// One session-bound OAuth attempt. Completion consumes it before awaiting any +/// network operation. State/provider/expiry validation happens before dispatch. +public actor OAuthAttempt { + public nonisolated let authorizationURL: URL + public nonisolated let provider: OAuthProvider + private let state: String + private let binding: OAuthSessionBinding + private let startedAt: Date + private let transport: any OAuthSessionTransport + private enum State { case pending, completing, cancelled, finished } + private var stateOfAttempt = State.pending + + internal init( + url: URL, provider: OAuthProvider, state: String, binding: OAuthSessionBinding, + startedAt: Date, transport: any OAuthSessionTransport + ) { + authorizationURL = url; self.provider = provider; self.state = state + self.binding = binding; self.startedAt = startedAt; self.transport = transport + } + + public func cancel() async { + stateOfAttempt = .cancelled + await transport.cancelOAuth(binding: binding) + } + + public func complete( + provider callbackProvider: OAuthProvider, code: String, state callbackState: String, + inviteCode: String = "", now: Date = Date() + ) async throws -> LoginResponse { + guard stateOfAttempt == .pending else { throw ResearchAPIError.invalidCallback } + stateOfAttempt = .completing + do { + guard now >= startedAt, now.timeIntervalSince(startedAt) <= 15 * 60 else { + throw ResearchAPIError.expiredAuthenticationAttempt + } + guard callbackProvider == provider, callbackState == state, !code.isEmpty else { + throw ResearchAPIError.invalidCallback + } + let body = try APIJSON.encode(["code": code, "state": state, "invite_code": inviteCode]) + let response = try await transport.completeOAuth( + MapleRequest( + method: .post, + target: "/auth/\(provider.rawValue)/callback", headers: ["content-type": "application/json"], + body: body, auth: .none, allowSessionRecovery: false), binding: binding) + let api = ResearchAPI(transport: transport) + let checked = try await api.checked(response) + let data = try await api.collect(checked.body) + let login: LoginResponse + do { login = try APIJSON.decoder().decode(LoginResponse.self, from: data) } catch { + throw ResearchAPIError.invalidResponse + } + guard !login.id.isEmpty, !login.accessToken.isEmpty, !login.refreshToken.isEmpty else { + throw ResearchAPIError.invalidResponse + } + try Task.checkCancellation() + guard stateOfAttempt == .completing else { throw ResearchAPIError.invalidCallback } + stateOfAttempt = .finished + return login + } catch { + stateOfAttempt = .finished + await transport.cancelOAuth(binding: binding) + throw error + } + } +} + +extension ResearchAPI { + public func initiateOAuth( + provider: OAuthProvider, clientId: String, inviteCode: String? = nil, + redirectURL: URL? = nil, now: Date = Date() + ) async throws -> OAuthAttempt { + guard let transport = transport as? any OAuthSessionTransport else { throw ResearchAPIError.invalidArgument } + var fields: [String: JSONValue] = ["client_id": .string(clientId)] + if let inviteCode, !inviteCode.isEmpty { fields["invite_code"] = .string(inviteCode) } + if let redirectURL { fields["redirect_url"] = .string(redirectURL.absoluteString) } + let (response, binding) = try await transport.beginOAuth( + MapleRequest( + method: .post, + target: "/auth/\(provider.rawValue)", headers: ["content-type": "application/json"], + body: try APIJSON.encode(fields), auth: .none)) + do { + let checked = try await checked(response) + let data = try await collect(checked.body) + let result: OAuthInitiationResponse + do { result = try APIJSON.decoder().decode(OAuthInitiationResponse.self, from: data) } catch { + throw ResearchAPIError.invalidResponse + } + guard let state = provider == .apple ? result.state : result.csrfToken, !state.isEmpty, + let url = URL(string: result.authUrl), url.scheme == "https", url.host != nil, + url.user == nil, url.password == nil + else { throw ResearchAPIError.invalidResponse } + try Task.checkCancellation() + return OAuthAttempt( + url: url, provider: provider, state: state, binding: binding, + startedAt: now, transport: transport) + } catch { + await transport.cancelOAuth(binding: binding) + throw error + } + } + + /// Apple receives SHA-256(rawNonce); the enclave receives rawNonce and checks + /// the identity-token nonce. This API requires it even though older SDKs made + /// the field optional. The caller retains attempt ownership during acquisition. + public func handleAppleNativeSignIn( + identityToken: String, userIdentifier: String, + rawNonce: String, clientId: String, email: String? = nil, + givenName: String? = nil, familyName: String? = nil, + inviteCode: String? = nil + ) async throws -> LoginResponse { + guard !identityToken.isEmpty, !userIdentifier.isEmpty, !rawNonce.isEmpty else { + throw ResearchAPIError.invalidArgument + } + var fields: [String: JSONValue] = [ + "identity_token": .string(identityToken), + "user_identifier": .string(userIdentifier), "nonce": .string(rawNonce), "client_id": .string(clientId), + ] + if let email { fields["email"] = .string(email) } + if let givenName { fields["given_name"] = .string(givenName) } + if let familyName { fields["family_name"] = .string(familyName) } + if let inviteCode, !inviteCode.isEmpty { fields["invite_code"] = .string(inviteCode) } + return try await login("/auth/apple/native", fields) + } +} + +public struct NativeOAuthHandoffGrant: Sendable, Equatable, CustomStringConvertible, CustomDebugStringConvertible { + public let rawValue: String + public var description: String { "NativeOAuthHandoffGrant()" } + public var debugDescription: String { description } + + public init(validating value: String) throws { + let segments = value.split(separator: ".", omittingEmptySubsequences: false) + guard value.utf8.count <= 4_096, segments.count == 3, + segments.allSatisfy({ Self.isCanonicalBase64URL(String($0)) }) + else { + throw ResearchAPIError.invalidCallback + } + rawValue = value + } + + /// Validates the envelope only. The enclave verifies the grant's signature, + /// expiry, account, and exact native session/request binding during redemption. + public init(callbackURL: URL, expectedScheme: String) throws { + guard let parts = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false), + parts.scheme == expectedScheme, parts.host == "auth", parts.user == nil, + parts.password == nil, parts.port == nil, parts.fragment == nil, + parts.path.isEmpty || parts.path == "/", + let query = parts.queryItems, query.count == 1, + query[0].name == "handoff_grant", let grant = query[0].value + else { + throw ResearchAPIError.invalidCallback + } + try self.init(validating: grant) + } + + private static func isCanonicalBase64URL(_ value: String) -> Bool { + guard !value.isEmpty, value.utf8.count % 4 != 1, + value.utf8.allSatisfy({ + (65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || $0 == 45 || $0 == 95 + }) + else { return false } + let standard = value.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + guard let decoded = Data(base64Encoded: standard + String(repeating: "=", count: (4 - standard.count % 4) % 4)) + else { return false } + return decoded.base64EncodedString().replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") == value + } +} diff --git a/sdk/swift/Sources/MapleSDK/API/ResearchAPI.swift b/sdk/swift/Sources/MapleSDK/API/ResearchAPI.swift new file mode 100644 index 000000000..d8287ac00 --- /dev/null +++ b/sdk/swift/Sources/MapleSDK/API/ResearchAPI.swift @@ -0,0 +1,504 @@ +import Foundation + +/// Research's API surface over an injected attested transport. This layer never +/// retries. An account-scoped instance carries the principal into every request. +/// Authentication responses must be installed by the credential owner using its +/// pre-request compare-and-swap snapshot, before publishing a signed-in account. +public struct ResearchAPI: Sendable { + public let transport: any MapleTransport + public let expectedPrincipalId: String? + public let maximumResponseBytes: Int + + public init( + transport: any MapleTransport, expectedPrincipalId: String? = nil, + maximumResponseBytes: Int = 64 * 1_024 * 1_024 + ) { + self.transport = transport + self.expectedPrincipalId = expectedPrincipalId + self.maximumResponseBytes = maximumResponseBytes + } + + public func signIn(email: String, password: String, clientId: String) async throws -> LoginResponse { + try await login( + "/login", ["email": .string(email), "password": .string(password), "client_id": .string(clientId)]) + } + + public func signInGuest(id: String, password: String, clientId: String) async throws -> LoginResponse { + try await login("/login", ["id": .string(id), "password": .string(password), "client_id": .string(clientId)]) + } + + public func signUp( + email: String, password: String, inviteCode: String, clientId: String, + name: String? = nil + ) async throws -> LoginResponse { + var fields: [String: JSONValue] = [ + "email": .string(email), "password": .string(password), + "inviteCode": .string(inviteCode.lowercased()), "client_id": .string(clientId), + ] + if let name { fields["name"] = .string(name) } + return try await login("/register", fields) + } + + /// SDK parity only. Applications decide whether guest creation is offered. + public func signUpGuest(password: String, inviteCode: String, clientId: String) async throws -> LoginResponse { + try await login( + "/register", + [ + "password": .string(password), "inviteCode": .string(inviteCode.lowercased()), + "client_id": .string(clientId), + ]) + } + + public func getUser() async throws -> MapleUser { + let result: UserResponse = try await json(.get, "/protected/user") + guard expectedPrincipalId == nil || result.user.id == expectedPrincipalId else { + throw ResearchAPIError.invalidResponse + } + return result.user + } + + /// Remote revocation only. The account owner invalidates local work and clears + /// credentials even when this best-effort network operation fails. + public func signOut(refreshToken: String) async throws { + try await empty(.post, "/logout", fields: ["refresh_token": .string(refreshToken)], auth: .none) + } + + public func verifyEmail(code: String) async throws { + try await empty(.get, "/verify-email/\(try Self.segment(code))", auth: .none) + } + + public func requestNewVerificationEmail() async throws { + try await empty(.post, "/protected/request_verification") + } + + public func requestPasswordReset(email: String, hashedSecret: String, clientId: String) async throws { + try await empty( + .post, "/password-reset/request", + fields: [ + "email": .string(email), + "hashed_secret": .string(hashedSecret), "client_id": .string(clientId), + ], auth: .none) + } + + public func confirmPasswordReset( + email: String, code: String, plaintextSecret: String, + newPassword: String, clientId: String + ) async throws { + try await empty( + .post, "/password-reset/confirm", + fields: [ + "email": .string(email), + "alphanumeric_code": .string(code), "plaintext_secret": .string(plaintextSecret), + "new_password": .string(newPassword), "client_id": .string(clientId), + ], auth: .none) + } + + /// A returned token pair must be installed atomically against the authority + /// which started this request. Never install a partial pair. + public func changePassword(currentPassword: String, newPassword: String) async throws -> CredentialUpdateResponse { + let result: CredentialUpdateResponse = try await json( + .post, "/protected/change_password", + fields: [ + "current_password": .string(currentPassword), "new_password": .string(newPassword), + ]) + guard (result.accessToken == nil) == (result.refreshToken == nil), + result.accessToken != "", result.refreshToken != "" + else { throw ResearchAPIError.invalidResponse } + return result + } + + public func requestAccountDeletion(hashedSecret: String) async throws { + try await empty(.post, "/protected/delete-account/request", fields: ["hashed_secret": .string(hashedSecret)]) + } + + public func confirmAccountDeletion(code: String, plaintextSecret: String) async throws { + try await empty( + .post, "/protected/delete-account/confirm", + fields: [ + "confirmation_code": .string(code), "plaintext_secret": .string(plaintextSecret), + ]) + } + + public func generateThirdPartyToken(audience: String? = nil) async throws -> ThirdPartyTokenResponse { + try await json(.post, "/protected/third_party_token", fields: audience.map { ["audience": .string($0)] } ?? [:]) + } + + public func createConversation( + metadata: [String: JSONValue]? = nil, projectId: String? = nil, + pinned: Bool? = nil + ) async throws -> Conversation { + var fields: [String: JSONValue] = [:] + if let metadata { fields["metadata"] = .object(metadata) } + if let projectId { fields["project_id"] = .string(projectId) } + if let pinned { fields["pinned"] = .bool(pinned) } + return try await json(.post, "/v1/conversations", fields: fields) + } + + public func getConversation(id: String) async throws -> Conversation { + try await json(.get, "/v1/conversations/\(try Self.segment(id))") + } + + public func listConversations(_ parameters: ConversationsListParameters = .init()) async throws -> APIPage< + Conversation + > { + var query = try parameters.page.query() + if let id = parameters.projectId { query.append(("project_id", id)) } + if let value = parameters.unassignedProject { query.append(("unassigned_project", String(value))) } + if let value = parameters.pinned { query.append(("pinned", String(value))) } + return try await json(.get, Self.target("/v1/conversations", query)) + } + + public func updateConversation( + id: String, metadata: [String: JSONValue]? = nil, + projectId: NullableUpdate = .unchanged, pinned: Bool? = nil + ) async throws -> Conversation { + var fields: [String: JSONValue] = [:] + if let metadata { fields["metadata"] = .object(metadata) } + switch projectId { + case .unchanged: break + case .clear: fields["project_id"] = .null + case let .set(value): fields["project_id"] = .string(value) + } + if let pinned { fields["pinned"] = .bool(pinned) } + guard !fields.isEmpty else { throw ResearchAPIError.invalidArgument } + return try await json(.post, "/v1/conversations/\(try Self.segment(id))", fields: fields) + } + + public func deleteConversation(id: String) async throws -> DeletedObject { + try await json(.delete, "/v1/conversations/\(try Self.segment(id))") + } + + public func deleteAllConversations() async throws -> DeletedObject { + try await json(.delete, "/v1/conversations") + } + + public func batchDeleteConversations(ids: [String]) async throws -> BatchDeleteResponse { + try await json(.post, "/v1/conversations/batch-delete", fields: ["ids": .array(ids.map(JSONValue.string))]) + } + + public func batchUpdateConversationProject(ids: [String], projectId: String?) async throws + -> BatchUpdateProjectResponse + { + try await json( + .post, "/v1/conversations/batch-update-project", + fields: [ + "ids": .array(ids.map(JSONValue.string)), "project_id": projectId.map(JSONValue.string) ?? .null, + ]) + } + + public func listConversationItems(conversationId: String, parameters: ListParameters = .init()) async throws + -> APIPage + { + try await json( + .get, Self.target("/v1/conversations/\(try Self.segment(conversationId))/items", parameters.query())) + } + + public func getConversationItem(conversationId: String, itemId: String) async throws -> ConversationItem { + try await json(.get, "/v1/conversations/\(try Self.segment(conversationId))/items/\(try Self.segment(itemId))") + } + + public func createProject(name: String) async throws -> ConversationProject { + try await json(.post, "/v1/conversation-projects", fields: ["name": .string(name)]) + } + + public func listProjects(_ parameters: ListParameters = .init()) async throws -> APIPage< + ConversationProjectListItem + > { + guard parameters.before == nil else { throw ResearchAPIError.invalidArgument } + return try await json(.get, Self.target("/v1/conversation-projects", parameters.query())) + } + + public func getProject(id: String) async throws -> ConversationProject { + try await json(.get, "/v1/conversation-projects/\(try Self.segment(id))") + } + + public func updateProject( + id: String, name: String? = nil, + instructions: NullableUpdate = .unchanged + ) async throws -> ConversationProject { + var fields: [String: JSONValue] = [:] + if let name { fields["name"] = .string(name) } + switch instructions { + case .unchanged: break + case .clear: fields["instructions"] = .null + case let .set(value): fields["instructions"] = .string(value) + } + guard !fields.isEmpty else { throw ResearchAPIError.invalidArgument } + return try await json(.post, "/v1/conversation-projects/\(try Self.segment(id))", fields: fields) + } + + public func deleteProject(id: String) async throws -> DeletedObject { + try await json(.delete, "/v1/conversation-projects/\(try Self.segment(id))") + } + + public func createInstruction(name: String, prompt: String, isDefault: Bool? = nil) async throws -> Instruction { + var fields: [String: JSONValue] = ["name": .string(name), "prompt": .string(prompt)] + if let isDefault { fields["is_default"] = .bool(isDefault) } + return try await json(.post, "/v1/instructions", fields: fields) + } + + public func listInstructions(_ parameters: ListParameters = .init()) async throws -> APIPage { + guard parameters.before == nil else { throw ResearchAPIError.invalidArgument } + return try await json(.get, Self.target("/v1/instructions", parameters.query())) + } + + public func getInstruction(id: String) async throws -> Instruction { + try await json(.get, "/v1/instructions/\(try Self.segment(id))") + } + + public func updateInstruction( + id: String, name: String? = nil, prompt: String? = nil, + isDefault: Bool? = nil + ) async throws -> Instruction { + var fields: [String: JSONValue] = [:] + if let name { fields["name"] = .string(name) } + if let prompt { fields["prompt"] = .string(prompt) } + if let isDefault { fields["is_default"] = .bool(isDefault) } + guard !fields.isEmpty else { throw ResearchAPIError.invalidArgument } + return try await json(.post, "/v1/instructions/\(try Self.segment(id))", fields: fields) + } + + public func deleteInstruction(id: String) async throws -> DeletedObject { + try await json(.delete, "/v1/instructions/\(try Self.segment(id))") + } + + public func setDefaultInstruction(id: String) async throws -> Instruction { + try await json(.post, "/v1/instructions/\(try Self.segment(id))/set-default") + } + + public func fetchModels(authenticated: Bool = true) async throws -> [Model] { + struct Models: Decodable { let object: String; let data: [Model] } + let result: Models = try await json(.get, "/v1/models", auth: authenticated ? .required : .none) + guard result.object == "list" else { throw ResearchAPIError.invalidResponse } + return result.data + } + + public func fetchModelCatalog() async throws -> ModelCatalog { + let result: ModelCatalog = try await json(.get, "/v1/models/catalog") + guard result.object == "list" else { throw ResearchAPIError.invalidResponse } + return result + } + + public func transcribeAudio( + data: Data, filename: String = "audio", contentType: String = "audio/mpeg", + model: String = "whisper-large-v3", language: String? = nil, + prompt: String? = nil, temperature: Double? = nil + ) async throws -> TranscriptionResponse { + var fields: [String: JSONValue] = [ + "file": .string(data.base64EncodedString()), "filename": .string(filename), + "content_type": .string(contentType), "model": .string(model), + ] + if let language, !language.isEmpty { fields["language"] = .string(language) } + if let prompt, !prompt.isEmpty { fields["prompt"] = .string(prompt) } + if let temperature { fields["temperature"] = .number(temperature) } + return try await json(.post, "/v1/audio/transcriptions", fields: fields) + } + + /// Returns decrypted audio bytes without eagerly buffering an entire reply. + public func synthesizeSpeech( + input: String, voice: String, speed: Double = 1, + model: String = "voxtral-tts", format: String = "wav" + ) async throws -> MapleResponse { + try await request( + .post, "/v1/audio/speech", + fields: [ + "input": .string(input), "voice": .string(voice), + "speed": .number(speed), "model": .string(model), "response_format": .string(format), + ]) + } + + public func retrieveResponse(id: String) async throws -> ResearchResponse { + try await json(.get, "/v1/responses/\(try Self.segment(id))") + } + + public func cancelResponse(id: String) async throws -> ResearchResponse { + try await json(.post, "/v1/responses/\(try Self.segment(id))/cancel") + } + + internal func login(_ target: String, _ fields: [String: JSONValue]) async throws -> LoginResponse { + let result: LoginResponse = try await json(.post, target, fields: fields, auth: .none) + guard !result.id.isEmpty, !result.accessToken.isEmpty, !result.refreshToken.isEmpty else { + throw ResearchAPIError.invalidResponse + } + return result + } + + internal func json( + _ method: MapleHTTPMethod, _ target: String, + fields: [String: JSONValue]? = nil, + auth: MapleRequestAuthentication = .required + ) async throws -> T { + let response = try await request(method, target, fields: fields, auth: auth) + let data = try await collect(response.body) + do { return try APIJSON.decoder().decode(T.self, from: data) } catch { throw ResearchAPIError.invalidResponse } + } + + internal func empty( + _ method: MapleHTTPMethod, _ target: String, + fields: [String: JSONValue]? = nil, + auth: MapleRequestAuthentication = .required + ) async throws { + let response = try await request(method, target, fields: fields, auth: auth) + _ = try await collect(response.body) + } + + internal func request( + _ method: MapleHTTPMethod, _ target: String, + fields: [String: JSONValue]? = nil, + auth: MapleRequestAuthentication = .required + ) async throws -> MapleResponse { + let body = try fields.map { try APIJSON.encode($0) } + return try await checked( + try await transport.send( + MapleRequest( + method: method, target: target, + headers: fields == nil ? [:] : ["content-type": "application/json"], body: body, + auth: auth, expectedPrincipalId: expectedPrincipalId))) + } + + internal func checked(_ response: MapleResponse) async throws -> MapleResponse { + guard !(200...299).contains(response.status) else { return response } + // Consume the authenticated tunnel to EOF before trusting error headers. + let body = try await collect(response.body, limit: 1_024 * 1_024) + if let capacity = OpenSecretInferenceCapacityError(response: response) { throw capacity } + var reason: ResearchAPIError.RejectionReason = .unspecified + // Recognize only stable, bounded public contracts. Never expose the body. + let text = String(data: body, encoding: .utf8) ?? "" + if let chatReason = Self.chatRejection(response, body: body) { + reason = chatReason + } else if response.status == 503 && response.header("x-opensecret-error-contract") == "1" + && response.header("x-opensecret-error-code") == "image_description_unavailable" + { + reason = .imageDescriptionUnavailable + } else if response.status == 503 && response.header("x-opensecret-error-contract") == "1" + && response.header("x-opensecret-error-code") == "billing_account_deletion_failed" + { + reason = .billingCleanupUnavailable + } else if response.status == 401 && text.contains("Invalid email, password, or login method") { + reason = .invalidLogin + } else if response.status == 401 && text.contains("Invalid invite code") { + reason = .invalidInviteCode + } else if response.status == 409 && text.contains("Email already registered") { + reason = .emailAlreadyRegistered + } else if response.status == 409 && text.contains("User exists but Oauth not linked") { + reason = .oauthNotLinked + } else if response.status == 404 && text.contains("User not found") { + reason = .userNotFound + } else if response.status == 400 && text.contains("No valid email found for the Oauth account") { + reason = .oauthEmailUnavailable + } else if response.status == 400 && text.contains("User is already verified") { + reason = .alreadyVerified + } else if response.status == 413 { + reason = .payloadTooLarge + } else if response.status == 429 { + reason = .rateLimited + } else if response.status == 401 { + reason = .authenticationRequired + } else if response.status == 403 { + reason = .forbidden + } else if response.status == 404 { + reason = .notFound + } + throw ResearchAPIError.http(status: response.status, reason: reason) + } + + private static func chatRejection(_ response: MapleResponse, body: Data) -> ResearchAPIError.RejectionReason? { + let contract = response.header("x-opensecret-error-contract") + guard contract == nil || contract == "1" else { return nil } + let headerCode = response.header("x-opensecret-error-code") + guard headerCode == nil || contract == "1" else { return nil } + // Unrelated payload fields must not hide a supplied conflicting code. + let fields = try? JSONDecoder().decode(ChatErrorBody.self, from: body) + let codes = [headerCode.map(ChatErrorBody.Code.value), fields?.code, fields?.nestedCode] + .compactMap { $0 } + .filter { $0 != .absent } + let code: String + if let first = codes.first { + // Every supplied source must agree. Unknown or conflicting codes + // must never fall back to a human-readable limit message. + guard case let .value(value) = first, codes.allSatisfy({ $0 == first }) else { return nil } + code = value + } else { + // Older servers publish only the flat status and exact message. + // Bound that compatibility path and reject arbitrary substrings. + guard body.count <= 4_096, fields?.status == Int64(response.status), let message = fields?.message + else { return nil } + switch message { + case "Usage limit reached": code = "usage_limit_reached" + case "Free tier token limit exceeded": code = "free_tier_token_limit_exceeded" + case "Message exceeds context limit": code = "message_exceeds_context_limit" + case "Model not available on current plan": code = "model_not_available_on_plan" + default: return nil + } + } + switch (response.status, code) { + case (403, "usage_limit_reached"): return .usageLimit + case (403, "free_tier_token_limit_exceeded"): return .freeTierTokenLimit + case (413, "message_exceeds_context_limit"): return .contextLimit + case (403, "model_not_available_on_plan"): return .modelUnavailable + case (400, "unsupported_value"): return .unsupportedValue + default: return nil + } + } + + private struct ChatErrorBody: Decodable { + enum Code: Equatable { + case absent, value(String), invalid + } + let code: Code + let nestedCode: Code + let status: Int64? + let message: String? + enum CodingKeys: String, CodingKey { case code, error, status, message } + + init(from decoder: any Decoder) throws { + let fields = try decoder.container(keyedBy: CodingKeys.self) + code = Self.readCode(fields) + if let error = try? fields.nestedContainer(keyedBy: CodingKeys.self, forKey: .error) { + nestedCode = Self.readCode(error) + } else { + nestedCode = .absent + } + status = try? fields.decode(Int64.self, forKey: .status) + message = try? fields.decode(String.self, forKey: .message) + } + + private static func readCode(_ fields: KeyedDecodingContainer) -> Code { + guard fields.contains(.code), (try? fields.decodeNil(forKey: .code)) != true else { return .absent } + guard let value = try? fields.decode(String.self, forKey: .code) else { return .invalid } + return .value(value) + } + } + + internal func collect(_ stream: AsyncThrowingStream, limit: Int? = nil) async throws -> Data { + var data = Data() + let limit = limit ?? maximumResponseBytes + for try await chunk in stream { + try Task.checkCancellation() + guard limit >= 0, chunk.count <= limit - data.count else { throw ResearchAPIError.responseTooLarge } + data.append(chunk) + } + try Task.checkCancellation() + return data + } + + internal static func segment(_ value: String) throws -> String { + guard !value.isEmpty, value != ".", value != "..", + !value.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) + else { + throw ResearchAPIError.invalidArgument + } + return value.addingPercentEncoding(withAllowedCharacters: unreserved) ?? "" + } + + internal static let unreserved = CharacterSet( + charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~") + + internal static func target(_ path: String, _ query: [(String, String)]) -> String { + guard !query.isEmpty else { return path } + return path + "?" + + query.map { name, value in + name + "=" + (value.addingPercentEncoding(withAllowedCharacters: unreserved) ?? "") + }.joined(separator: "&") + } +} diff --git a/sdk/swift/Sources/MapleSDK/API/ResearchModels.swift b/sdk/swift/Sources/MapleSDK/API/ResearchModels.swift new file mode 100644 index 000000000..ab8fc8530 --- /dev/null +++ b/sdk/swift/Sources/MapleSDK/API/ResearchModels.swift @@ -0,0 +1,589 @@ +import Foundation + +public enum ModelAliasId: String, Codable, Sendable { + case quick = "auto:quick", powerful = "auto:powerful" +} + +public enum ResponseStatus: String, Codable, Sendable { + case queued, inProgress = "in_progress", completed, failed, cancelled, incomplete +} + +public struct LoginResponse: Codable, Sendable, Equatable { + public var id: String + public var email: String? + public var accessToken: String + public var refreshToken: String + + public init(id: String, email: String? = nil, accessToken: String, refreshToken: String) { + self.id = id + self.email = email + self.accessToken = accessToken + self.refreshToken = refreshToken + } +} + +public struct UserResponse: Codable, Sendable, Equatable { + public var user: MapleUser + + public init(user: MapleUser) { + self.user = user + } +} + +public struct MapleUser: Codable, Sendable, Equatable { + public var id: String + public var name: String? + public var email: String? + public var emailVerified: Bool + public var loginMethod: String + public var createdAt: String + public var updatedAt: String + + public init( + id: String, name: String? = nil, email: String? = nil, emailVerified: Bool, loginMethod: String, + createdAt: String, updatedAt: String + ) { + self.id = id + self.name = name + self.email = email + self.emailVerified = emailVerified + self.loginMethod = loginMethod + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +public struct CredentialUpdateResponse: Codable, Sendable, Equatable { + public var message: String + public var accessToken: String? + public var refreshToken: String? + + public init(message: String, accessToken: String? = nil, refreshToken: String? = nil) { + self.message = message + self.accessToken = accessToken + self.refreshToken = refreshToken + } +} + +public struct ThirdPartyTokenResponse: Codable, Sendable, Equatable { + public var token: String + + public init(token: String) { + self.token = token + } +} + +public struct OAuthInitiationResponse: Codable, Sendable, Equatable { + public var authUrl: String + public var csrfToken: String? + public var state: String? + + public init(authUrl: String, csrfToken: String? = nil, state: String? = nil) { + self.authUrl = authUrl + self.csrfToken = csrfToken + self.state = state + } +} + +public struct Conversation: Codable, Sendable, Equatable { + public var id: String + public var object: String + public var createdAt: Int64 + public var metadata: [String: JSONValue]? + public var projectId: String? + public var pinned: Bool + public var lastActivityAt: Int64 + + public init( + id: String, object: String, createdAt: Int64, metadata: [String: JSONValue]? = nil, projectId: String? = nil, + pinned: Bool, lastActivityAt: Int64 + ) { + self.id = id + self.object = object + self.createdAt = createdAt + self.metadata = metadata + self.projectId = projectId + self.pinned = pinned + self.lastActivityAt = lastActivityAt + } +} + +public struct ConversationItem: Codable, Sendable, Equatable { + public var id: String + public var responseId: String? + public var createdAt: Int64? + public var type: String + public var status: String? + public var role: String? + public var content: [MessageContent]? + public var callId: String? + public var name: String? + public var arguments: JSONValue? + public var output: JSONValue? + + public init( + id: String, responseId: String? = nil, createdAt: Int64? = nil, type: String, status: String? = nil, + role: String? = nil, content: [MessageContent]? = nil, callId: String? = nil, name: String? = nil, + arguments: JSONValue? = nil, output: JSONValue? = nil + ) { + self.id = id + self.responseId = responseId + self.createdAt = createdAt + self.type = type + self.status = status + self.role = role + self.content = content + self.callId = callId + self.name = name + self.arguments = arguments + self.output = output + } +} + +public struct MessageContent: Codable, Sendable, Equatable { + public var type: String + public var text: String? + public var filename: String? + public var audio: String? + public var transcript: String? + public var id: String? + public var imageUrl: JSONValue? + public var detail: String? + public var annotations: [JSONValue]? + + public init( + type: String, text: String? = nil, filename: String? = nil, audio: String? = nil, transcript: String? = nil, + id: String? = nil, imageUrl: JSONValue? = nil, detail: String? = nil, annotations: [JSONValue]? = nil + ) { + self.type = type + self.text = text + self.filename = filename + self.audio = audio + self.transcript = transcript + self.id = id + self.imageUrl = imageUrl + self.detail = detail + self.annotations = annotations + } +} + +public struct DeletedObject: Codable, Sendable, Equatable { + public var id: String? + public var object: String + public var deleted: Bool + + public init(id: String? = nil, object: String, deleted: Bool) { + self.id = id + self.object = object + self.deleted = deleted + } +} + +public struct BatchDeleteItem: Codable, Sendable, Equatable { + public var id: String + public var object: String + public var deleted: Bool + public var error: String? + + public init(id: String, object: String, deleted: Bool, error: String? = nil) { + self.id = id + self.object = object + self.deleted = deleted + self.error = error + } +} + +public struct BatchDeleteResponse: Codable, Sendable, Equatable { + public var object: String + public var data: [BatchDeleteItem] + + public init(object: String, data: [BatchDeleteItem]) { + self.object = object + self.data = data + } +} + +public struct BatchUpdateProjectResponse: Codable, Sendable, Equatable { + public var success: Bool + + public init(success: Bool) { + self.success = success + } +} + +public struct ConversationProject: Codable, Sendable, Equatable { + public var id: String + public var object: String + public var name: String + public var instructions: String? + public var createdAt: Int64 + public var updatedAt: Int64 + + public init( + id: String, object: String, name: String, instructions: String? = nil, createdAt: Int64, updatedAt: Int64 + ) { + self.id = id + self.object = object + self.name = name + self.instructions = instructions + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +public struct ConversationProjectListItem: Codable, Sendable, Equatable { + public var id: String + public var object: String + public var name: String + public var createdAt: Int64 + public var updatedAt: Int64 + + public init(id: String, object: String, name: String, createdAt: Int64, updatedAt: Int64) { + self.id = id + self.object = object + self.name = name + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +public struct Instruction: Codable, Sendable, Equatable { + public var id: String + public var object: String + public var name: String + public var prompt: String + public var promptTokens: Int + public var isDefault: Bool + public var createdAt: Int64 + public var updatedAt: Int64 + + public init( + id: String, object: String, name: String, prompt: String, promptTokens: Int, isDefault: Bool, createdAt: Int64, + updatedAt: Int64 + ) { + self.id = id + self.object = object + self.name = name + self.prompt = prompt + self.promptTokens = promptTokens + self.isDefault = isDefault + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +public struct Model: Codable, Sendable, Equatable { + public var id: String + public var object: String + public var created: Int64 + public var ownedBy: String + + public init(id: String, object: String, created: Int64, ownedBy: String) { + self.id = id + self.object = object + self.created = created + self.ownedBy = ownedBy + } +} + +public struct ModelCapabilities: Codable, Sendable, Equatable { + public var chat: Bool + public var vision: Bool + public var reasoning: Bool + public var toolUse: Bool + + public init(chat: Bool, vision: Bool, reasoning: Bool, toolUse: Bool) { + self.chat = chat + self.vision = vision + self.reasoning = reasoning + self.toolUse = toolUse + } +} + +/// OpenAI wire spellings; each model or alias publishes the subset it accepts. +public enum ReasoningEffort: String, Codable, CaseIterable, Sendable { + case none, minimal, low, medium, high, xhigh, max +} + +public struct ModelReasoning: Codable, Sendable, Equatable { + public var mandatory: Bool + public var defaultEnabled: Bool? + /// Preserve future catalog values without making the entire catalog unreadable. + public var supportedEfforts: [String]? + public var defaultEffort: String? + + public init( + mandatory: Bool, defaultEnabled: Bool? = nil, supportedEfforts: [String]? = nil, + defaultEffort: String? = nil + ) { + self.mandatory = mandatory; self.defaultEnabled = defaultEnabled + self.supportedEfforts = supportedEfforts; self.defaultEffort = defaultEffort + } +} + +public struct ModelCatalogItem: Codable, Sendable, Equatable { + public var id: String + public var object: String + public var created: Int64 + public var ownedBy: String + public var provider: String? + public var providerId: String? + public var displayName: String + public var shortName: String + public var description: String? + public var contextWindow: Int + public var maxContextTokens: Int + public var maxCompletionTokens: Int? + public var access: String + public var capabilities: ModelCapabilities + public var reasoning: ModelReasoning? + public var tasks: [String]? + public var badges: [String]? + public var enabled: Bool + public var deprecated: Bool + public var sortOrder: Int? + + public init( + id: String, object: String, created: Int64, ownedBy: String, provider: String? = nil, providerId: String? = nil, + displayName: String, shortName: String, description: String? = nil, contextWindow: Int, maxContextTokens: Int, + access: String, capabilities: ModelCapabilities, tasks: [String]? = nil, badges: [String]? = nil, enabled: Bool, + deprecated: Bool, sortOrder: Int? = nil, maxCompletionTokens: Int? = nil, reasoning: ModelReasoning? = nil + ) { + self.id = id + self.object = object + self.created = created + self.ownedBy = ownedBy + self.provider = provider + self.providerId = providerId + self.displayName = displayName + self.shortName = shortName + self.description = description + self.contextWindow = contextWindow + self.maxContextTokens = maxContextTokens + self.maxCompletionTokens = maxCompletionTokens + self.access = access + self.capabilities = capabilities + self.reasoning = reasoning + self.tasks = tasks + self.badges = badges + self.enabled = enabled + self.deprecated = deprecated + self.sortOrder = sortOrder + } +} + +public struct ModelAlias: Codable, Sendable, Equatable { + public var id: ModelAliasId + public var label: String + public var shortName: String + public var description: String + public var targetModel: String + public var access: String + public var capabilities: ModelCapabilities + public var maxCompletionTokens: Int? + public var reasoning: ModelReasoning? + + public init( + id: ModelAliasId, label: String, shortName: String, description: String, targetModel: String, access: String, + capabilities: ModelCapabilities, maxCompletionTokens: Int? = nil, reasoning: ModelReasoning? = nil + ) { + self.id = id + self.label = label + self.shortName = shortName + self.description = description + self.targetModel = targetModel + self.access = access + self.capabilities = capabilities + self.maxCompletionTokens = maxCompletionTokens + self.reasoning = reasoning + } +} + +public struct ModelDefaults: Codable, Sendable, Equatable { + public var quick: ModelAliasId + public var powerful: ModelAliasId + + public init(quick: ModelAliasId, powerful: ModelAliasId) { + self.quick = quick + self.powerful = powerful + } +} + +public struct AudioModelAvailability: Codable, Sendable, Equatable { + public var available: Bool + public var model: String + public var displayName: String? + + public init(available: Bool, model: String, displayName: String? = nil) { + self.available = available + self.model = model + self.displayName = displayName + } +} + +public struct AudioModelCatalog: Codable, Sendable, Equatable { + public var transcription: AudioModelAvailability? + public var speech: AudioModelAvailability? + + public init(transcription: AudioModelAvailability? = nil, speech: AudioModelAvailability? = nil) { + self.transcription = transcription + self.speech = speech + } +} + +public struct ModelCatalog: Codable, Sendable, Equatable { + public var object: String + public var data: [ModelCatalogItem] + public var aliases: [ModelAlias] + public var defaults: ModelDefaults + public var audio: AudioModelCatalog? + + public init( + object: String, data: [ModelCatalogItem], aliases: [ModelAlias], defaults: ModelDefaults, + audio: AudioModelCatalog? = nil + ) { + self.object = object + self.data = data + self.aliases = aliases + self.defaults = defaults + self.audio = audio + } +} + +public struct TranscriptionResponse: Codable, Sendable, Equatable { + public var text: String + + public init(text: String) { + self.text = text + } +} + +public struct ResponseUsage: Codable, Sendable, Equatable { + public var inputTokens: Int + public var inputTokensDetails: InputTokenDetails? + public var outputTokens: Int + public var outputTokensDetails: OutputTokenDetails? + public var totalTokens: Int + + public init( + inputTokens: Int, inputTokensDetails: InputTokenDetails? = nil, outputTokens: Int, + outputTokensDetails: OutputTokenDetails? = nil, totalTokens: Int + ) { + self.inputTokens = inputTokens + self.inputTokensDetails = inputTokensDetails + self.outputTokens = outputTokens + self.outputTokensDetails = outputTokensDetails + self.totalTokens = totalTokens + } +} + +public struct InputTokenDetails: Codable, Sendable, Equatable { + public var cachedTokens: Int + + public init(cachedTokens: Int) { + self.cachedTokens = cachedTokens + } +} + +public struct OutputTokenDetails: Codable, Sendable, Equatable { + public var reasoningTokens: Int + + public init(reasoningTokens: Int) { + self.reasoningTokens = reasoningTokens + } +} + +public struct ResponseFailure: Codable, Sendable, Equatable { + public var code: String + public var message: String + + public init(code: String, message: String) { + self.code = code + self.message = message + } +} + +public struct ResearchResponse: Codable, Sendable, Equatable { + public var id: String + public var object: String + public var createdAt: Int64 + public var status: ResponseStatus + public var model: String + public var usage: ResponseUsage? + public var output: [ConversationItem]? + public var error: ResponseFailure? + public var metadata: [String: JSONValue]? + + public init( + id: String, object: String, createdAt: Int64, status: ResponseStatus, model: String, + usage: ResponseUsage? = nil, output: [ConversationItem]? = nil, error: ResponseFailure? = nil, + metadata: [String: JSONValue]? = nil + ) { + self.id = id + self.object = object + self.createdAt = createdAt + self.status = status + self.model = model + self.usage = usage + self.output = output + self.error = error + self.metadata = metadata + } +} + +public struct APIPage: Codable, Sendable, Equatable { + public let object: String + public let data: [Element] + public let firstId: String? + public let lastId: String? + public let hasMore: Bool + + public init( + object: String = "list", data: [Element], firstId: String? = nil, + lastId: String? = nil, hasMore: Bool = false + ) { + self.object = object + self.data = data + self.firstId = firstId + self.lastId = lastId + self.hasMore = hasMore + } +} + +public enum ListOrder: String, Codable, Sendable { case asc, desc } + +public struct ListParameters: Sendable { + public var limit: Int? + public var after: String? + public var before: String? + public var order: ListOrder? + + public init(limit: Int? = nil, after: String? = nil, before: String? = nil, order: ListOrder? = nil) { + self.limit = limit; self.after = after; self.before = before; self.order = order + } + + internal func query() throws -> [(String, String)] { + if let limit, !(1...100).contains(limit) { throw ResearchAPIError.invalidArgument } + var result: [(String, String)] = [] + if let limit { result.append(("limit", String(limit))) } + if let after { result.append(("after", after)) } + if let before { result.append(("before", before)) } + if let order { result.append(("order", order.rawValue)) } + return result + } +} + +public struct ConversationsListParameters: Sendable { + public var page: ListParameters + public var projectId: String? + public var unassignedProject: Bool? + public var pinned: Bool? + + public init( + page: ListParameters = .init(), projectId: String? = nil, + unassignedProject: Bool? = nil, pinned: Bool? = nil + ) { + self.page = page; self.projectId = projectId + self.unassignedProject = unassignedProject; self.pinned = pinned + } +} diff --git a/sdk/swift/Sources/MapleSDK/API/ResponsesAPI.swift b/sdk/swift/Sources/MapleSDK/API/ResponsesAPI.swift new file mode 100644 index 000000000..746c29c06 --- /dev/null +++ b/sdk/swift/Sources/MapleSDK/API/ResponsesAPI.swift @@ -0,0 +1,337 @@ +import Foundation + +public enum ResponseInput: Encodable, Sendable { + case text(String) + case messages([ResponseInputMessage]) + public func encode(to encoder: any Encoder) throws { + var value = encoder.singleValueContainer() + switch self { + case let .text(text): try value.encode(text) + case let .messages(messages): try value.encode(messages) + } + } +} + +public struct ResponseInputMessage: Encodable, Sendable { + public let role: String + public let content: [ResponseInputContent] + public init(role: String = "user", content: [ResponseInputContent]) { + self.role = role; self.content = content + } +} + +public enum ResponseInputContent: Encodable, Sendable { + case text(String) + case image(url: String, detail: String = "auto") + private enum CodingKeys: String, CodingKey { case type, text, imageUrl, detail } + public func encode(to encoder: any Encoder) throws { + var value = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .text(text): + try value.encode("input_text", forKey: .type) + try value.encode(text, forKey: .text) + case let .image(url, detail): + try value.encode("input_image", forKey: .type) + try value.encode(url, forKey: .imageUrl) + try value.encode(detail, forKey: .detail) + } + } +} + +public struct ResponsesCreateRequest: Encodable, Sendable { + public var model: String + public var input: ResponseInput + public var conversation: String + public var metadata: [String: JSONValue]? + public var stream = true + public var store = true + public var tools: [ResponseTool]? + public var reasoning: ResponseReasoning? + + public init( + model: String = "auto:quick", input: ResponseInput, conversation: String, + metadata: [String: JSONValue]? = nil, webSearch: Bool = false, reasoningEffort: ReasoningEffort? = nil + ) { + self.model = model; self.input = input; self.conversation = conversation + self.metadata = metadata; self.tools = webSearch ? [ResponseTool()] : nil + self.reasoning = reasoningEffort.map { ResponseReasoning(effort: $0) } + } +} + +public struct ResponseReasoning: Encodable, Sendable, Equatable { + public var effort: ReasoningEffort + public init(effort: ReasoningEffort) { self.effort = effort } +} + +public struct ResponseTool: Encodable, Sendable { + public let type = "web_search" + public init() {} +} + +public struct ResponseLifecycleEvent: Decodable, Sendable, Equatable { + public let response: ResearchResponse + public let sequenceNumber: Int? + public let opensecret: ResponseErrorContract? +} + +public struct ResponseErrorContract: Decodable, Sendable, Equatable { + public let errorContract: String + public let errorCode: String +} + +public struct ResponseTextEvent: Decodable, Sendable, Equatable { + public let itemId: String + public let outputIndex: Int + public let contentIndex: Int + public let sequenceNumber: Int? + public let delta: String? + public let text: String? +} + +public struct ResponseItemEvent: Decodable, Sendable, Equatable { + public let outputIndex: Int + public let item: ConversationItem + public let sequenceNumber: Int? +} + +public struct ResponseContentEvent: Decodable, Sendable, Equatable { + public let itemId: String + public let outputIndex: Int + public let contentIndex: Int + public let part: MessageContent + public let sequenceNumber: Int? +} + +public struct ResponseWebSearchEvent: Decodable, Sendable, Equatable { + public let itemId: String + public let outputIndex: Int + public let sequenceNumber: Int? +} + +public struct ToolCallEvent: Decodable, Sendable, Equatable { + public let toolCallId: String + public let outputIndex: Int + public let name: String + public let arguments: JSONValue + public let sequenceNumber: Int? +} + +public struct ToolOutputEvent: Decodable, Sendable, Equatable { + public let toolOutputId: String + public let toolCallId: String + public let outputIndex: Int + public let output: String + public let sequenceNumber: Int? +} + +public struct ResponseCancelledEvent: Decodable, Sendable, Equatable { + public struct Payload: Decodable, Sendable, Equatable { public let id: String } + public let id: String? + public let data: Payload? + public let response: ResearchResponse? +} + +public struct ResponseStreamFailure: Decodable, Sendable, Equatable { + public let code: String? + public let message: String? + public let error: ResponseFailure? +} + +public enum ResponseStreamEvent: Sendable, Equatable { + case created(ResponseLifecycleEvent), inProgress(ResponseLifecycleEvent) + case outputItemAdded(ResponseItemEvent), outputItemDone(ResponseItemEvent) + case contentPartAdded(ResponseContentEvent), contentPartDone(ResponseContentEvent) + case outputTextDelta(ResponseTextEvent), outputTextDone(ResponseTextEvent) + case reasoningTextDelta(ResponseTextEvent), reasoningTextDone(ResponseTextEvent) + case webSearchInProgress(ResponseWebSearchEvent), webSearchSearching(ResponseWebSearchEvent), webSearchCompleted( + ResponseWebSearchEvent) + case toolCallCreated(ToolCallEvent), toolOutputCreated(ToolOutputEvent) + case completed(ResponseLifecycleEvent), failed(ResponseLifecycleEvent) + case cancelled(ResponseCancelledEvent), error(ResponseStreamFailure) + case unknown(type: String, payload: JSONValue) + + public var isTerminal: Bool { + switch self { + case .completed, .failed, .cancelled, .error: true + default: false + } + } +} + +extension ResearchAPI { + /// Event buffering preserves every event across consumer stalls; SSE event + /// sizes remain bounded and cancellation cancels the network consumer. A + /// terminal event is withheld until authenticated transport EOF is verified. + public func createResponse( + _ request: ResponsesCreateRequest, + maximumInferenceSends: Int = 2 + ) async throws -> AsyncThrowingStream { + guard request.stream, maximumInferenceSends == 1 || maximumInferenceSends == 2 else { + throw ResearchAPIError.invalidArgument + } + let response = try await checked( + try await transport.send( + MapleRequest( + method: .post, target: "/v1/responses", + headers: ["content-type": "application/json", "accept": "text/event-stream"], + body: try APIJSON.encode(request), expectedPrincipalId: expectedPrincipalId, + maximumInferenceSends: maximumInferenceSends))) + guard + response.header("content-type")?.split(separator: ";").first?.trimmingCharacters(in: .whitespaces) + .lowercased() == "text/event-stream" + else { + throw ResearchAPIError.invalidResponse + } + return AsyncThrowingStream(bufferingPolicy: .unbounded) { continuation in + let task = Task { + do { + var decoder = ResponsesSSEDecoder() + for try await chunk in response.body { + try Task.checkCancellation() + for event in try decoder.append(chunk) { continuation.yield(event) } + } + try Task.checkCancellation() + for event in try decoder.finish() { continuation.yield(event) } + continuation.finish() + } catch { continuation.finish(throwing: error) } + } + continuation.onTermination = { @Sendable _ in task.cancel() } + } + } +} + +/// Incremental SSE parser, independent of network chunk boundaries and UTF-8 +/// boundaries. Unknown event types remain forward-compatible but cannot finish +/// a response. A bare [DONE], malformed JSON, or EOF cannot manufacture success. +public struct ResponsesSSEDecoder: Sendable { + private var buffer = Data() + private var eventName: String? + private var dataLines: [String] = [] + private var eventBytes = 0 + private var terminal: ResponseStreamEvent? + private var sawDone = false + private var finished = false + public let maximumEventBytes: Int + + public init(maximumEventBytes: Int = 16 * 1_024 * 1_024) { + self.maximumEventBytes = maximumEventBytes + } + + public mutating func append(_ chunk: Data) throws -> [ResponseStreamEvent] { + guard !finished else { throw ResearchAPIError.invalidStream } + var events: [ResponseStreamEvent] = [] + // Process one byte at a time so a large transport chunk containing many + // small events does not count as one over-sized SSE event. + for byte in chunk { + if buffer.last == 13 { + buffer.removeLast() + if let event = try consumeLine(buffer) { events.append(event) } + buffer.removeAll(keepingCapacity: true) + if byte == 10 { continue } + } + if byte == 10 { + if let event = try consumeLine(buffer) { events.append(event) } + buffer.removeAll(keepingCapacity: true) + } else { + guard buffer.count < maximumEventBytes - eventBytes else { throw ResearchAPIError.responseTooLarge } + buffer.append(byte) + } + } + return events + } + + public mutating func finish() throws -> [ResponseStreamEvent] { + guard !finished else { throw ResearchAPIError.invalidStream } + finished = true + if buffer.last == 13 { + buffer.removeLast() + _ = try consumeLine(buffer) + buffer.removeAll() + } + guard buffer.isEmpty, dataLines.isEmpty, eventName == nil, let terminal else { + throw ResearchAPIError.truncatedStream + } + return [terminal] + } + + private mutating func consumeLine(_ data: Data) throws -> ResponseStreamEvent? { + guard let line = String(data: data, encoding: .utf8) else { throw ResearchAPIError.invalidStream } + if line.isEmpty { + defer { eventName = nil; dataLines.removeAll(keepingCapacity: true); eventBytes = 0 } + guard !dataLines.isEmpty else { return nil } + let text = dataLines.joined(separator: "\n") + if text == "[DONE]" { + guard terminal != nil, !sawDone else { throw ResearchAPIError.invalidStream } + sawDone = true + return nil + } + guard terminal == nil, !sawDone else { throw ResearchAPIError.invalidStream } + let event = try Self.decode(name: eventName, data: Data(text.utf8)) + if event.isTerminal { terminal = event; return nil } + return event + } + if line.hasPrefix(":") { return nil } + let parts = line.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) + let name = String(parts[0]) + var value = parts.count > 1 ? String(parts[1]) : "" + if value.hasPrefix(" ") { value.removeFirst() } + eventBytes += data.count + 1 + guard eventBytes <= maximumEventBytes else { throw ResearchAPIError.responseTooLarge } + switch name { + case "event": eventName = value + case "data": dataLines.append(value) + default: break // id/retry are irrelevant; reconnecting would replay inference. + } + return nil + } + + private static func decode(name: String?, data: Data) throws -> ResponseStreamEvent { + struct Envelope: Decodable { let type: String } + let decoder = APIJSON.decoder() + do { + let type = try decoder.decode(Envelope.self, from: data).type + guard name == nil || name == type else { throw ResearchAPIError.invalidStream } + func parse(_ type: T.Type) throws -> T { try decoder.decode(type, from: data) } + switch type { + case "response.created": return .created(try parse(ResponseLifecycleEvent.self)) + case "response.in_progress": return .inProgress(try parse(ResponseLifecycleEvent.self)) + case "response.output_item.added": return .outputItemAdded(try parse(ResponseItemEvent.self)) + case "response.output_item.done": return .outputItemDone(try parse(ResponseItemEvent.self)) + case "response.content_part.added": return .contentPartAdded(try parse(ResponseContentEvent.self)) + case "response.content_part.done": return .contentPartDone(try parse(ResponseContentEvent.self)) + case "response.output_text.delta", "response.reasoning_text.delta": + let event = try parse(ResponseTextEvent.self) + guard event.delta != nil else { throw ResearchAPIError.invalidStream } + return type == "response.output_text.delta" ? .outputTextDelta(event) : .reasoningTextDelta(event) + case "response.output_text.done", "response.reasoning_text.done": + let event = try parse(ResponseTextEvent.self) + guard event.text != nil else { throw ResearchAPIError.invalidStream } + return type == "response.output_text.done" ? .outputTextDone(event) : .reasoningTextDone(event) + case "response.web_search_call.in_progress": + return .webSearchInProgress(try parse(ResponseWebSearchEvent.self)) + case "response.web_search_call.searching": + return .webSearchSearching(try parse(ResponseWebSearchEvent.self)) + case "response.web_search_call.completed": + return .webSearchCompleted(try parse(ResponseWebSearchEvent.self)) + case "tool_call.created": return .toolCallCreated(try parse(ToolCallEvent.self)) + case "tool_output.created": return .toolOutputCreated(try parse(ToolOutputEvent.self)) + case "response.completed": + let event = try parse(ResponseLifecycleEvent.self) + guard event.response.status == .completed else { throw ResearchAPIError.invalidStream } + return .completed(event) + case "response.failed": + let event = try parse(ResponseLifecycleEvent.self) + guard event.response.status == .failed else { throw ResearchAPIError.invalidStream } + return .failed(event) + case "response.cancelled": + let event = try parse(ResponseCancelledEvent.self) + guard event.data?.id != nil || event.response?.status == .cancelled else { + throw ResearchAPIError.invalidStream + } + return .cancelled(event) + case "error", "response.error": return .error(try parse(ResponseStreamFailure.self)) + default: return .unknown(type: type, payload: try JSONDecoder().decode(JSONValue.self, from: data)) + } + } catch { throw ResearchAPIError.invalidStream } + } +} diff --git a/sdk/swift/Sources/MapleSDK/Credentials/ClientRegistry.swift b/sdk/swift/Sources/MapleSDK/Credentials/ClientRegistry.swift new file mode 100644 index 000000000..528ffc3e6 --- /dev/null +++ b/sdk/swift/Sources/MapleSDK/Credentials/ClientRegistry.swift @@ -0,0 +1,36 @@ +import Foundation + +/// Keychain-backed clients share live authority for their entire process. +/// The lock protects synchronous construction only; network work stays actor-isolated. +final class ClientRegistry: @unchecked Sendable { + static let shared = ClientRegistry() + private let lock = NSLock() + private var authorities: [String: CredentialAuthority] = [:] + private var transports: [String: EncryptedTransport] = [:] + + func components( + apiURL: String, policy: PCRTrustPolicy, store: any SecureStore, + namespace: String + ) throws -> (CredentialAuthority, EncryptedTransport) { + lock.lock() + defer { lock.unlock() } + let endpoint = try APIEndpoint(apiURL) + let authorityKey = hashSecret(namespace + "\n" + endpoint.origin) + let authority: CredentialAuthority + if let saved = authorities[authorityKey] { + authority = saved + } else { + authority = try CredentialAuthority(apiURL: apiURL, store: store) + authorities[authorityKey] = authority + } + let transportKey = hashSecret(namespace + "\n" + endpoint.url.absoluteString + "\n" + policy.cacheIdentity) + let transport: EncryptedTransport + if let saved = transports[transportKey] { + transport = saved + } else { + transport = try EncryptedTransport(apiURL: apiURL, policy: policy, credentials: authority, store: store) + transports[transportKey] = transport + } + return (authority, transport) + } +} diff --git a/sdk/swift/Sources/MapleSDK/Credentials/CredentialAuthority.swift b/sdk/swift/Sources/MapleSDK/Credentials/CredentialAuthority.swift new file mode 100644 index 000000000..9a3c1219e --- /dev/null +++ b/sdk/swift/Sources/MapleSDK/Credentials/CredentialAuthority.swift @@ -0,0 +1,329 @@ +import Foundation +import Security + +/// Storage is synchronous so a durable commit and publication cannot interleave. +public protocol SecureStore: Sendable { + func read(key: String) throws -> Data? + func write(_ data: Data, key: String) throws + func remove(key: String) throws +} + +/// Device-local, non-synchronizing Keychain storage. Never writes tokens to preferences. +public struct KeychainSecureStore: SecureStore { + let service: String + public init(service: String = "ai.maple.sdk.transport-v2") { self.service = service } + private func query(_ key: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, + kSecAttrAccount as String: key, kSecAttrSynchronizable as String: false, + ] + } + public func read(key: String) throws -> Data? { + var query = query(key) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let data = result as? Data else { throw TransportError.persistence } + return data + } + public func write(_ data: Data, key: String) throws { + let attributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + let status = SecItemUpdate(query(key) as CFDictionary, attributes as CFDictionary) + if status == errSecItemNotFound { + let added = SecItemAdd(query(key).merging(attributes) { _, new in new } as CFDictionary, nil) + guard added == errSecSuccess else { throw TransportError.persistence } + } else if status != errSecSuccess { + throw TransportError.persistence + } + } + public func remove(key: String) throws { + let status = SecItemDelete(query(key) as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { throw TransportError.persistence } + } +} + +public struct CredentialSnapshot: Sendable, Equatable { + public let apiOrigin: String + public let principalId: String? + public let revision: UInt64 + /// Changes on authentication replacement and sign-out, including same-account re-login. + public let generation: UUID + + func ownsSameAccount(as other: Self) -> Bool { + apiOrigin == other.apiOrigin && principalId == other.principalId && generation == other.generation + } +} + +/// Token-free authority state. Revisions from routine refresh are deliberately +/// absent: only authentication replacement, revocation and cleanup alter this state. +public struct CredentialAuthorityChange: Sendable, Equatable { + public let apiOrigin: String + public let generation: UUID + public let requiresCleanup: Bool + + public init(apiOrigin: String, generation: UUID, requiresCleanup: Bool) { + self.apiOrigin = apiOrigin; self.generation = generation; self.requiresCleanup = requiresCleanup + } +} + +struct CredentialPair: Sendable, Codable { + let accessToken: String + let refreshToken: String + let principal: String + let accessExpiry: Int + let refreshExpiry: Int + + init(access: String, refresh: String) throws { + let accessHints = try Self.hints(access, audience: "access") + let refreshHints = try Self.hints(refresh, audience: "refresh") + guard accessHints.0 == refreshHints.0 else { throw TransportError.invalidCredentials } + accessToken = access; refreshToken = refresh + principal = accessHints.0; accessExpiry = accessHints.1; refreshExpiry = refreshHints.1 + } + + /// JWT claims are unverified hints; the server verifies signature and authorization. + static func hints(_ token: String, audience: String) throws -> (String, Int) { + guard token.utf8.count <= 16 * 1024 else { throw TransportError.invalidCredentials } + let segments = token.split(separator: ".", omittingEmptySubsequences: false) + guard segments.count == 3 else { throw TransportError.invalidCredentials } + var payload = Data() + for (index, segment) in segments.enumerated() { + guard !segment.isEmpty, + segment.utf8.allSatisfy({ + (65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || $0 == 45 || $0 == 95 + }), segment.count % 4 != 1 + else { throw TransportError.invalidCredentials } + let base = segment.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + guard let bytes = Data(base64Encoded: base + String(repeating: "=", count: (4 - base.count % 4) % 4)), + bytes.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences( + of: "/", with: "_" + ).replacingOccurrences(of: "=", with: "") == segment + else { + throw TransportError.invalidCredentials + } + if index == 1 { payload = bytes } + } + guard String(data: payload, encoding: .utf8) != nil, + let claims = try? JSONSerialization.jsonObject(with: payload) as? [String: Any], + claims["aud"] as? String == "urn:opensecret:internal:transport-v2:user:\(audience)-token", + let principal = claims["sub"] as? String, !principal.isEmpty, principal.utf16.count <= 256, + let expiry = WireProtocol.integer(claims["exp"]), expiry > 0, + WireProtocol.integer(claims["tf"]) == 2 + else { throw TransportError.invalidCredentials } + return (principal, expiry) + } +} + +/// Share one authority per API origin across clients in a process. +/// Revision checks defend async credential installation; generations fence account-owned streams. +public actor CredentialAuthority { + public nonisolated let apiOrigin: String + private let store: any SecureStore + private let storageKey: String + private var state: State + private var cleanupFailed = false + private var observers: [UUID: AsyncStream.Continuation] = [:] + private var refreshing: (id: UUID, snapshot: CredentialSnapshot, task: Task)? + + private struct State: Codable { + var version = 1 + var origin: String + var revision: UInt64 = 0 + var generation = UUID() + var access: String? + var refresh: String? + var cacheRoot: Data? + } + + public init(apiURL: String, store: any SecureStore = KeychainSecureStore()) throws { + apiOrigin = try APIEndpoint(apiURL).origin + self.store = store + storageKey = "authority:" + apiOrigin + if let data = try store.read(key: storageKey) { + do { + guard data.count <= 96 * 1024, let saved = try? JSONDecoder().decode(State.self, from: data), + saved.version == 1, saved.origin == apiOrigin, + saved.cacheRoot == nil || saved.cacheRoot?.count == 32, + (saved.access == nil) == (saved.refresh == nil) + else { throw TransportError.invalidCredentials } + if let access = saved.access, let refresh = saved.refresh { + _ = try CredentialPair(access: access, refresh: refresh) + } + state = saved + // A new process never adopts another process's in-flight ownership leases. + state.generation = UUID() + } catch { + // A malformed origin has no usable authority. Subscribers receive + // this new signed-out generation, or a cleanup requirement if the + // corrupt durable state could not be removed. + state = State(origin: apiOrigin) + do { try store.remove(key: storageKey) } catch { cleanupFailed = true } + } + } else { + state = State(origin: apiOrigin) + } + } + + /// Registration and the initial value share this actor turn, so a subscriber + /// cannot miss a replacement between reading the current state and observing. + /// Slow consumers receive only the newest state and must recheck currentState(). + public func changes() -> AsyncStream { + let id = UUID() + let pair = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + pair.continuation.onTermination = { [weak self] _ in + Task { await self?.removeObserver(id) } + } + observers[id] = pair.continuation + pair.continuation.yield(currentState()) + return pair.stream + } + + public func currentState() -> CredentialAuthorityChange { + CredentialAuthorityChange(apiOrigin: apiOrigin, generation: state.generation, requiresCleanup: cleanupFailed) + } + + private func removeObserver(_ id: UUID) { observers.removeValue(forKey: id) } + var observerCount: Int { observers.count } + private func publish() { + let value = currentState() + for observer in observers.values { observer.yield(value) } + } + + /// Retries only an already-revoked authority's failed durable cleanup. It + /// cannot clear credentials installed by another caller while this awaited. + public func completePendingCleanup() throws { + guard cleanupFailed else { return } + try commit(state) + cleanupFailed = false + publish() + } + + public func snapshot() throws -> CredentialSnapshot { + guard !cleanupFailed else { throw TransportError.persistence } + return CredentialSnapshot( + apiOrigin: apiOrigin, principalId: try pair()?.principal, + revision: state.revision, generation: state.generation) + } + func pair() throws -> CredentialPair? { + guard !cleanupFailed else { throw TransportError.persistence } + guard let access = state.access, let refresh = state.refresh else { return nil } + return try CredentialPair(access: access, refresh: refresh) + } + public func authenticatedUserId() throws -> String? { try pair()?.principal } + func assertCurrent(_ expected: CredentialSnapshot, exactRevision: Bool = true) throws { + let current = try snapshot() + guard current.apiOrigin == expected.apiOrigin, current.principalId == expected.principalId, + current.generation == expected.generation, + !exactRevision || current.revision == expected.revision + else { throw TransportError.authorityChanged } + } + public func isCurrent(_ expected: CredentialSnapshot) -> Bool { (try? snapshot()) == expected } + + private func commit(_ updated: State) throws { + let data = try JSONEncoder().encode(updated) + guard data.count <= 96 * 1024 else { throw TransportError.resourceLimit } + try store.write(data, key: storageKey) + state = updated + } + private func nextState() throws -> State { + guard state.revision < UInt64.max else { throw TransportError.invalidCredentials } + var updated = state; updated.revision += 1 + return updated + } + + @discardableResult + public func install(accessToken: String, refreshToken: String, expected: CredentialSnapshot) throws + -> CredentialSnapshot + { + try install(accessToken: accessToken, refreshToken: refreshToken, expected: expected, refreshing: false) + } + @discardableResult + func install(accessToken: String, refreshToken: String, expected: CredentialSnapshot, refreshing: Bool) throws + -> CredentialSnapshot + { + try Task.checkCancellation() + try assertCurrent(expected) + let credentials = try CredentialPair(access: accessToken, refresh: refreshToken) + if refreshing && credentials.principal != expected.principalId { throw TransportError.invalidCredentials } + var updated = try nextState() + updated.access = accessToken; updated.refresh = refreshToken + if !refreshing { updated.generation = UUID() } + try commit(updated) + if !refreshing { publish() } + return try snapshot() + } + + /// Invalidate live authority before cleanup; a Keychain failure blocks further use until cleanup succeeds. + public func clear(expected: CredentialSnapshot? = nil) throws { + if let expected { try assertCurrent(expected) } + var updated = try nextState() + updated.access = nil; updated.refresh = nil; updated.generation = UUID() + state = updated + defer { publish() } + do { try commit(updated); cleanupFailed = false } catch { + cleanupFailed = true; throw TransportError.persistence + } + } + + /// Capture the refresh token and revoke the exact owning generation in one + /// actor turn. A routine refresh cannot interleave between capture and clear. + func revoke(expected: CredentialSnapshot?) throws -> String? { + try completePendingCleanup() + if let expected { try assertCurrent(expected, exactRevision: false) } + let refresh = try pair()?.refreshToken + try clear() + return refresh + } + + /// An account deletion invalidates the account lease even if a routine + /// refresh completed while the server was processing the deletion. + func clearAccount(expected: CredentialSnapshot) throws { + try assertCurrent(expected, exactRevision: false) + try clear() + } + + /// All transports for this authority coalesce token rotation, including + /// clients that select a different endpoint path or attestation policy. + func coalescedRefresh( + expected: CredentialSnapshot, + operation: @escaping @Sendable () async throws -> Void + ) async throws { + try assertCurrent(expected, exactRevision: false) + if state.revision != expected.revision { return } + if let pending = refreshing, pending.snapshot == expected { + try await pending.task.value + try Task.checkCancellation() + return + } + let id = UUID() + let task = Task { + defer { finishRefresh(id: id) } + try await operation() + } + refreshing = (id, expected, task) + try await task.value + try Task.checkCancellation() + } + + private func finishRefresh(id: UUID) { + if refreshing?.id == id { refreshing = nil } + } + + func cacheRoot() throws -> Data { + guard !cleanupFailed else { throw TransportError.persistence } + if let root = state.cacheRoot { return root } + var updated = state + let root = try TransportCrypto.random(count: 32) + updated.cacheRoot = root + // The namespace can remain process-local when its cache cannot be saved. + // Credential replacement and revocation still require durable commits. + state = updated + try? commit(updated) + return root + } +} diff --git a/sdk/swift/Sources/MapleSDK/MapleClient.swift b/sdk/swift/Sources/MapleSDK/MapleClient.swift new file mode 100644 index 000000000..b15cf0c04 --- /dev/null +++ b/sdk/swift/Sources/MapleSDK/MapleClient.swift @@ -0,0 +1,153 @@ +import Foundation + +/// One account authority and transport per configured API origin. Keep this client +/// for the application's lifetime; navigation must not create competing authorities. +public final class MapleClient: Sendable { + public let transport: EncryptedTransport + public let credentials: CredentialAuthority + public let clientID: String + + public init( + apiURL: String, clientID: String, policy: PCRTrustPolicy, + store: any SecureStore = KeychainSecureStore() + ) throws { + guard UUID(uuidString: clientID) != nil else { throw ResearchAPIError.invalidArgument } + self.clientID = clientID + if let keychain = store as? KeychainSecureStore { + (credentials, transport) = try ClientRegistry.shared.components( + apiURL: apiURL, + policy: policy, store: keychain, namespace: keychain.service) + } else { + credentials = try CredentialAuthority(apiURL: apiURL, store: store) + transport = try EncryptedTransport(apiURL: apiURL, policy: policy, credentials: credentials, store: store) + } + } + + public var anonymousAPI: ResearchAPI { ResearchAPI(transport: transport) } + + /// Capture once per account session. An old API cannot silently use a new account. + public func accountAPI() async throws -> ResearchAPI { + let snapshot = try await credentials.snapshot() + guard let principal = snapshot.principalId else { throw TransportError.signedOut } + return ResearchAPI( + transport: AccountScopedTransport(transport: transport, snapshot: snapshot), expectedPrincipalId: principal) + } + + public func signIn(email: String, password: String) async throws -> CredentialSnapshot { + let snapshot = try await credentials.snapshot() + let result = try await anonymousAPI.signIn(email: email, password: password, clientId: clientID) + return try await install(result, expected: snapshot) + } + + public func signInGuest(id: String, password: String) async throws -> CredentialSnapshot { + let snapshot = try await credentials.snapshot() + let result = try await anonymousAPI.signInGuest(id: id, password: password, clientId: clientID) + return try await install(result, expected: snapshot) + } + + public func signUp(email: String, password: String, inviteCode: String = "", name: String? = nil) async throws + -> CredentialSnapshot + { + let snapshot = try await credentials.snapshot() + let result = try await anonymousAPI.signUp( + email: email, password: password, inviteCode: inviteCode, clientId: clientID, name: name) + return try await install(result, expected: snapshot) + } + + /// Use for an owned native Apple or browser OAuth completion. Capture expected before starting sign-in. + @discardableResult + public func install(_ login: LoginResponse, expected: CredentialSnapshot) async throws -> CredentialSnapshot { + let pair = try CredentialPair(access: login.accessToken, refresh: login.refreshToken) + guard pair.principal == login.id else { throw TransportError.invalidCredentials } + return try await credentials.install( + accessToken: login.accessToken, refreshToken: login.refreshToken, expected: expected) + } + + /// Pass the original account lease when sign-out follows asynchronous app + /// cleanup. Replacement credentials, including same-account re-login, are preserved. + public func signOut(expected: CredentialSnapshot? = nil) async throws { + let refresh = try await credentials.revoke(expected: expected) + await transport.cancelNativeOAuthHandoff() + if let refresh { try? await anonymousAPI.signOut(refreshToken: refresh) } + } + + /// Bind an account settings ceremony to its original lease. Routine token + /// renewal is allowed; replacement authentication, even for the same user, is not. + public func changePassword( + currentPassword: String, newPassword: String, expected: CredentialSnapshot? = nil + ) async throws { + let owner: CredentialSnapshot + if let expected { owner = expected } else { owner = try await credentials.snapshot() } + try await credentials.assertCurrent(owner, exactRevision: false) + let snapshot = try await transport.prepareAuthenticatedSnapshot() + guard snapshot.ownsSameAccount(as: owner) else { throw TransportError.authorityChanged } + let api = ResearchAPI( + transport: AccountScopedTransport(transport: transport, snapshot: snapshot), + expectedPrincipalId: snapshot.principalId) + let update = try await api.changePassword(currentPassword: currentPassword, newPassword: newPassword) + if let access = update.accessToken, let refresh = update.refreshToken { + _ = try await credentials.install( + accessToken: access, refreshToken: refresh, expected: snapshot, refreshing: true) + } else { + try await credentials.assertCurrent(snapshot) + } + } + + @discardableResult + public func refreshCredentials() async throws -> CredentialSnapshot { try await transport.refreshCredentials() } + + /// Supply the snapshot captured when requesting the deletion code so a + /// stale screen cannot adopt a subsequently signed-in account. + public func confirmAccountDeletion( + code: String, plaintextSecret: String, expected: CredentialSnapshot? = nil + ) async throws { + let snapshot: CredentialSnapshot + if let expected { snapshot = expected } else { snapshot = try await credentials.snapshot() } + guard snapshot.principalId != nil else { throw TransportError.signedOut } + try await credentials.assertCurrent(snapshot, exactRevision: false) + let api = ResearchAPI( + transport: AccountScopedTransport(transport: transport, snapshot: snapshot), + expectedPrincipalId: snapshot.principalId) + try await api.confirmAccountDeletion(code: code, plaintextSecret: plaintextSecret) + try await credentials.clearAccount(expected: snapshot) + await transport.cancelNativeOAuthHandoff() + } +} + +/// Retains the original generation even when the same account later signs in. +/// Routine refresh changes revisions but preserves this account lease. +private struct AccountScopedTransport: MapleTransport { + let transport: EncryptedTransport + let snapshot: CredentialSnapshot + func send(_ request: MapleRequest) async throws -> MapleResponse { + try await transport.credentials.assertCurrent(snapshot, exactRevision: false) + let response = try await transport.send(request, expectedAccount: snapshot) + try await transport.credentials.assertCurrent(snapshot, exactRevision: false) + let reader = AccountScopedBody(body: response.body, credentials: transport.credentials, snapshot: snapshot) + return MapleResponse( + status: response.status, headers: response.headers, + body: AsyncThrowingStream(unfolding: { try await reader.next() }), + inferenceSendCount: response.inferenceSendCount) + } +} + +private actor AccountScopedBody { + var iterator: AsyncThrowingStream.Iterator + let credentials: CredentialAuthority + let snapshot: CredentialSnapshot + var reading = false + init(body: AsyncThrowingStream, credentials: CredentialAuthority, snapshot: CredentialSnapshot) { + iterator = body.makeAsyncIterator(); self.credentials = credentials; self.snapshot = snapshot + } + func next() async throws -> Data? { + guard !reading else { throw TransportError.invalidFrame } + reading = true + defer { reading = false } + try await credentials.assertCurrent(snapshot, exactRevision: false) + var local = iterator + let chunk = try await local.next(isolation: self) + iterator = local + try await credentials.assertCurrent(snapshot, exactRevision: false) + return chunk + } +} diff --git a/sdk/swift/Sources/MapleSDK/Transport/EncryptedTransport.swift b/sdk/swift/Sources/MapleSDK/Transport/EncryptedTransport.swift new file mode 100644 index 000000000..970eb7ac8 --- /dev/null +++ b/sdk/swift/Sources/MapleSDK/Transport/EncryptedTransport.swift @@ -0,0 +1,520 @@ +import CryptoKit +import Foundation + +struct AttestedSession: Sendable, Codable { + let version: Int + let apiURL: String + let policyIdentity: String + let keys: SessionKeys + let routingKey: String + let expiresAt: Date + var identity: String { keys.id.lowercaseHex } + + func validate(endpoint: APIEndpoint, policy: PCRTrustPolicy, now: Date) throws { + guard version == 2, apiURL == endpoint.url.absoluteString, policyIdentity == policy.cacheIdentity, + keys.id.count == 16, keys.request.count == 32, keys.response.count == 32, + Data(canonicalBase64: routingKey, count: 32) != nil, + expiresAt.timeIntervalSince1970.isFinite, now < expiresAt + else { throw TransportError.invalidSession } + } +} + +/// Transport V2 only. Requests and credentials never leave as plaintext logical HTTP. +public actor EncryptedTransport: OAuthSessionTransport { + public nonisolated let credentials: CredentialAuthority + private let endpoint: APIEndpoint + private let policy: PCRTrustPolicy + private let network: any HTTPNetworking + private let store: any SecureStore + private let now: @Sendable () -> Date + private var session: AttestedSession? + private var restoreSessionCache = true + private var establishing: (id: UUID, task: Task)? + private var oauthBindings: [UUID: (AttestedSession, CredentialSnapshot, String)] = [:] + private var nativeBindings: [UUID: (AttestedSession, CredentialSnapshot, Data, Date)] = [:] + private var confirmations: [UUID: (CredentialSnapshot, LoginResponse, Date)] = [:] + + public init( + apiURL: String, policy: PCRTrustPolicy, credentials: CredentialAuthority, + store: any SecureStore = KeychainSecureStore() + ) throws { + try self.init( + apiURL: apiURL, policy: policy, credentials: credentials, + store: store, network: URLSessionNetworking(), now: Date.init) + } + + init( + apiURL: String, policy: PCRTrustPolicy, credentials: CredentialAuthority, + store: any SecureStore, network: any HTTPNetworking, now: @escaping @Sendable () -> Date + ) throws { + endpoint = try APIEndpoint(apiURL) + guard credentials.apiOrigin == endpoint.origin else { throw TransportError.invalidEndpoint } + self.policy = policy; self.credentials = credentials; self.store = store; self.network = network; self.now = now + } + + private var sessionStorageKey: String { + "session:" + hashSecret(endpoint.url.absoluteString + "\n" + policy.cacheIdentity) + } + + /// Persisted keys are isolated by exact endpoint and PCR policy. Corrupt/expired + /// sessions are discarded; malformed authenticated credentials are never anonymous. + private func ensureSession() async throws -> AttestedSession { + if let session, (try? session.validate(endpoint: endpoint, policy: policy, now: now())) != nil { + return session + } + if let pending = establishing { + let value = try await pending.task.value + guard establishing?.id == pending.id || session?.identity == value.identity else { + throw TransportError.expiredSession + } + try Task.checkCancellation() + return value + } + let cached = restoreSessionCache ? try? store.read(key: sessionStorageKey) : nil + restoreSessionCache = false + if let data = cached { + if data.count <= 16 * 1024, let restored = try? JSONDecoder().decode(AttestedSession.self, from: data), + (try? restored.validate(endpoint: endpoint, policy: policy, now: now())) != nil + { + session = restored; return restored + } + try? store.remove(key: sessionStorageKey) + } + let id = UUID() + let task = Task { [endpoint, policy, network, now] in + try await withThrowingTaskGroup(of: AttestedSession.self) { group in + group.addTask { + try await Self.establish(endpoint: endpoint, policy: policy, network: network, now: now) + } + group.addTask { + try await Task.sleep(for: .seconds(30)) + throw TransportError.establishmentTimeout + } + defer { group.cancelAll() } + return try await group.next()! + } + } + establishing = (id, task) + do { + let established = try await task.value + guard establishing?.id == id else { throw TransportError.expiredSession } + // Attestation remains authoritative; a cache failure cannot discard + // a verified session that is usable in this process. + try? store.write(JSONEncoder().encode(established), key: sessionStorageKey) + session = established; establishing = nil + try Task.checkCancellation() + return established + } catch { + if establishing?.id == id { establishing = nil } + throw error + } + } + + private static func establish( + endpoint: APIEndpoint, policy: PCRTrustPolicy, + network: any HTTPNetworking, now: @Sendable () -> Date + ) async throws -> AttestedSession { + let started = now() + let challenge = try TransportCrypto.random(count: 32) + let privateKey = Curve25519.KeyAgreement.PrivateKey() + let client = privateKey.publicKey.rawRepresentation + var request = URLRequest(url: endpoint.outerURL("v2/session")) + request.httpMethod = "POST"; request.timeoutInterval = 30 + request.setValue("application/json", forHTTPHeaderField: "content-type") + request.setValue(challenge.base64EncodedString(), forHTTPHeaderField: "x-opensecret-routing-key") + request.httpBody = try JSONSerialization.data(withJSONObject: [ + "version": 2, + "challenge": challenge.base64EncodedString(), "client_public_key": client.base64EncodedString(), + ]) + let connection = try await network.open(request) + guard connection.status == 200, + connection.headers["content-type"]?.split(separator: ";").first?.trimmingCharacters(in: .whitespaces) + .lowercased() == "application/json" + else { + connection.cancel(); throw TransportError.unauthenticatedResponse + } + let body = try await connection.collect(maximum: 64 * 1024) + let object = try WireProtocol.exactObject( + body, keys: ["version", "session_id", "attestation_document", "expires_in_seconds"]) + guard WireProtocol.integer(object["version"]) == 2, WireProtocol.integer(object["expires_in_seconds"]) == 3600, + let id = object["session_id"] as? String, id.utf8.count == 32, + id.utf8.allSatisfy({ (48...57).contains($0) || (97...102).contains($0) }), + let encoded = object["attestation_document"] as? String + else { throw TransportError.invalidSession } + let server: Data + let userData: Data? + let nonce: Data? + if endpoint.isLoopback { + let document = try AttestationVerifier.parseLoopbackDocument(base64: encoded) + server = document.publicKey ?? Data(); userData = document.userData; nonce = document.nonce + } else { + let document = try AttestationVerifier().verify(base64: encoded, nonce: challenge, deviceTime: now()) + _ = try await policy.requireTrustedPCR0(document.pcrs[0]) + server = document.publicKey ?? Data(); userData = document.userData; nonce = document.nonce + } + guard server.count == 32, nonce?.constantTimeEquals(challenge) == true, + userData?.constantTimeEquals(TransportCrypto.domain("session/v1/client-public-key") + client) == true + else { + throw TransportError.authentication + } + let shared: SharedSecret + do { + shared = try privateKey.sharedSecretFromKeyAgreement( + with: Curve25519.KeyAgreement.PublicKey(rawRepresentation: server)) + } catch { throw TransportError.authentication } + let keys = try TransportCrypto.derive( + shared: shared.withUnsafeBytes { Data($0) }, challenge: challenge, client: client, server: server) + guard keys.id.lowercaseHex == id else { throw TransportError.invalidSession } + let result = AttestedSession( + version: 2, apiURL: endpoint.url.absoluteString, policyIdentity: policy.cacheIdentity, + keys: keys, routingKey: challenge.base64EncodedString(), expiresAt: started.addingTimeInterval(3570)) + try result.validate(endpoint: endpoint, policy: policy, now: now()) + return result + } + + public func invalidateSession() { + establishing?.task.cancel(); establishing = nil; session = nil + // Never restore a retired session in this process, even if deleting its + // best-effort durable cache fails. + restoreSessionCache = false + oauthBindings.removeAll(); nativeBindings.removeAll() + try? store.remove(key: sessionStorageKey) + } + + private func retire(_ old: AttestedSession) { + if session?.identity == old.identity { invalidateSession() } + } + + public func send(_ request: MapleRequest) async throws -> MapleResponse { + try await send(request, expectedAccount: nil) + } + + func send(_ request: MapleRequest, expectedAccount: CredentialSnapshot?) async throws -> MapleResponse { + try Task.checkCancellation() + var snapshot = try await credentials.snapshot() + let originalAccount = snapshot + if let expectedAccount, !snapshot.ownsSameAccount(as: expectedAccount) { + throw TransportError.authorityChanged + } + if request.auth == .required { + guard let pair = try await credentials.pair() else { throw TransportError.signedOut } + if let expected = request.expectedPrincipalId, pair.principal != expected { + throw TransportError.authorityChanged + } + if pair.accessExpiry <= Int(now().timeIntervalSince1970) + 30 { + try await refreshCredentials(expected: snapshot) + snapshot = try await credentials.snapshot() + guard snapshot.ownsSameAccount(as: originalAccount) else { throw TransportError.authorityChanged } + } + } + if let expected = request.expectedPrincipalId, snapshot.principalId != expected { + throw TransportError.authorityChanged + } + let pair = try await credentials.pair() + try await credentials.assertCurrent(snapshot) + let credential: WireCredential? = + request.auth == .required ? pair.map { WireCredential(kind: .bearer, value: $0.accessToken) } : nil + let result = try await managed(request, credential: credential, snapshot: snapshot) + // Authenticated expiry can refresh future requests; never replay this operation. + if request.auth == .required, result.status == 401, + result.header("x-opensecret-error-contract") == "1", + result.header("x-opensecret-error-code") == "access_token_expired" + { + var bytes = Data() + for try await chunk in result.body { + guard bytes.count + chunk.count <= 64 * 1024 else { throw TransportError.resourceLimit } + bytes.append(chunk) + } + let authenticatedBody = bytes + try? await refreshCredentials(expected: snapshot) + return MapleResponse( + status: result.status, headers: result.headers, + body: AsyncThrowingStream { + $0.yield(authenticatedBody); $0.finish() + }, + inferenceSendCount: result.inferenceSendCount) + } + return result + } + + func prepareAuthenticatedSnapshot() async throws -> CredentialSnapshot { + let snapshot = try await credentials.snapshot() + guard let pair = try await credentials.pair() else { throw TransportError.signedOut } + if pair.accessExpiry <= Int(now().timeIntervalSince1970) + 30 { + try await refreshCredentials(expected: snapshot) + } + let current = try await credentials.snapshot() + guard current.ownsSameAccount(as: snapshot) else { throw TransportError.authorityChanged } + return current + } + + public func beginOAuth(_ request: MapleRequest) async throws -> (MapleResponse, OAuthSessionBinding) { + guard request.auth == .none, request.method == .post, + ["/auth/google", "/auth/github", "/auth/apple"].contains(request.target) + else { throw TransportError.invalidEnvelope } + let snapshot = try await credentials.snapshot() + guard snapshot.principalId == nil else { throw TransportError.authorityChanged } + let current = try await ensureSession() + let response = try await dispatch( + request, credential: nil, cacheRoot: nil, snapshot: snapshot, + session: current, requestID: TransportCrypto.random(count: 16), inferenceSendCount: 0) + try await credentials.assertCurrent(snapshot) + guard session?.identity == current.identity else { throw TransportError.expiredSession } + let binding = OAuthSessionBinding() + oauthBindings = oauthBindings.filter { $0.value.0.expiresAt > now() } + guard oauthBindings.count < 8 else { throw TransportError.resourceLimit } + oauthBindings[binding.id] = (current, snapshot, request.target + "/callback") + return (response, binding) + } + + public func cancelOAuth(binding: OAuthSessionBinding) { + oauthBindings.removeValue(forKey: binding.id) + } + + public func completeOAuth(_ request: MapleRequest, binding: OAuthSessionBinding) async throws -> MapleResponse { + guard let (retained, snapshot, callbackTarget) = oauthBindings.removeValue(forKey: binding.id), + session?.identity == retained.identity, request.auth == .none, request.method == .post, + request.target == callbackTarget + else { + throw TransportError.invalidSession + } + do { + let response = try await dispatch( + request, credential: nil, cacheRoot: nil, snapshot: snapshot, + session: retained, requestID: TransportCrypto.random(count: 16), inferenceSendCount: 0) + var data = Data() + for try await chunk in response.body { + guard data.count + chunk.count <= 64 * 1024 else { throw TransportError.resourceLimit } + data.append(chunk) + } + try await credentials.assertCurrent(snapshot) + guard session?.identity == retained.identity else { throw TransportError.expiredSession } + guard (200...299).contains(response.status) else { + throw TransportError.requestRejected(status: response.status) + } + return MapleResponse( + status: response.status, headers: response.headers, + body: AsyncThrowingStream { + $0.yield(data); $0.finish() + }, inferenceSendCount: 0) + } catch { retire(retained); throw error } + } + + public func prepareNativeOAuthHandoff() async throws -> PreparedNativeOAuthHandoff { + let snapshot = try await credentials.snapshot() + guard snapshot.principalId == nil else { throw TransportError.authorityChanged } + let current = try await ensureSession() + try await credentials.assertCurrent(snapshot) + guard session?.identity == current.identity, current.expiresAt > now() else { + throw TransportError.expiredSession + } + nativeBindings = nativeBindings.filter { $0.value.3 > now() } + guard nativeBindings.count < 8 else { throw TransportError.resourceLimit } + let id = UUID(), requestID = try TransportCrypto.random(count: 16) + let deadline = min(current.expiresAt, now().addingTimeInterval(15 * 60)) + nativeBindings[id] = (current, snapshot, requestID, deadline) + return PreparedNativeOAuthHandoff( + id: id, sessionID: current.identity, requestID: requestID.lowercaseHex, + expiresAt: deadline) + } + + /// Redemption is one-use and pinned to the reserved session/request. The + /// returned confirmation exposes identity only; credentials stay private until confirmation. + public func redeemNativeOAuthHandoff( + _ prepared: PreparedNativeOAuthHandoff, + grant: NativeOAuthHandoffGrant + ) async throws -> NativeSignInConfirmation { + guard let (retained, snapshot, requestID, deadline) = nativeBindings.removeValue(forKey: prepared.id), + session?.identity == retained.identity, deadline > now() + else { throw TransportError.invalidSession } + do { + let request = MapleRequest( + method: .post, target: "/auth/native-handoff/redeem", + headers: ["content-type": "application/json"], body: try APIJSON.encode(["grant": grant.rawValue]), + auth: .none, allowSessionRecovery: false) + let response = try await dispatch( + request, credential: nil, cacheRoot: nil, snapshot: snapshot, + session: retained, requestID: requestID, inferenceSendCount: 0) + var data = Data() + for try await chunk in response.body { + guard data.count + chunk.count <= 64 * 1024 else { throw TransportError.resourceLimit } + data.append(chunk) + } + guard (200...299).contains(response.status), + let login = try? APIJSON.decoder().decode(LoginResponse.self, from: data) + else { throw TransportError.invalidCredentials } + let pair = try CredentialPair(access: login.accessToken, refresh: login.refreshToken) + guard pair.principal == login.id, session?.identity == retained.identity else { + throw TransportError.authorityChanged + } + try await credentials.assertCurrent(snapshot) + retire(retained) + let id = UUID() + confirmations.removeAll() + confirmations[id] = (snapshot, login, deadline) + return NativeSignInConfirmation(id: id, principalID: login.id, email: login.email) + } catch { retire(retained); throw error } + } + + public func confirmNativeOAuthHandoff(_ confirmation: NativeSignInConfirmation) async throws -> CredentialSnapshot { + guard let (snapshot, login, deadline) = confirmations.removeValue(forKey: confirmation.id), + deadline > now() + else { throw TransportError.authorityChanged } + return try await credentials.install( + accessToken: login.accessToken, refreshToken: login.refreshToken, expected: snapshot) + } + + public func cancelNativeOAuthHandoff() { + confirmations.removeAll() + invalidateSession() + } + + private func refreshCredentials(expected: CredentialSnapshot) async throws { + try await credentials.coalescedRefresh(expected: expected) { [self] in + try await performRefresh(expected: expected) + } + } + + @discardableResult + public func refreshCredentials() async throws -> CredentialSnapshot { + let expected = try await credentials.snapshot() + try await refreshCredentials(expected: expected) + return try await credentials.snapshot() + } + + private func performRefresh(expected: CredentialSnapshot) async throws { + try await credentials.assertCurrent(expected) + guard let pair = try await credentials.pair() else { throw TransportError.signedOut } + let request = MapleRequest(method: .post, target: "/refresh", auth: .none) + let response = try await managed( + request, credential: WireCredential(kind: .resumption, value: pair.refreshToken), snapshot: expected) + var bytes = Data() + for try await chunk in response.body { + guard bytes.count + chunk.count <= 64 * 1024 else { throw TransportError.resourceLimit } + bytes.append(chunk) + } + guard (200...299).contains(response.status) else { + if response.status == 401 || response.status == 403 { + try await credentials.clear(expected: expected) + throw TransportError.signedOut + } + throw TransportError.requestRejected(status: response.status) + } + struct Tokens: Decodable { let access_token: String; let refresh_token: String } + guard let tokens = try? JSONDecoder().decode(Tokens.self, from: bytes) else { + throw TransportError.invalidCredentials + } + try await credentials.install( + accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expected: expected, refreshing: true) + } + + private func managed(_ request: MapleRequest, credential: WireCredential?, snapshot: CredentialSnapshot) + async throws -> MapleResponse + { + let inference = + request.target == "/v1/responses" && request.method == .post + || request.target.hasPrefix("/v1/audio/") && request.method == .post + var limit = request.maximumInferenceSends + let budgetHeaders = request.headers.filter { + $0.key.caseInsensitiveCompare(openSecretInferenceSendLimitHeader) == .orderedSame + } + guard budgetHeaders.count <= 1 else { throw TransportError.invalidEnvelope } + if let value = budgetHeaders.first?.value { + guard let headerLimit = Int(value), (1...2).contains(headerLimit), String(headerLimit) == value else { + throw TransportError.invalidEnvelope + } + limit = min(limit, headerLimit) + } + guard !inference || (1...2).contains(limit) else { throw TransportError.invalidEnvelope } + let root = credential == nil ? nil : try await credentials.cacheRoot() + var current = try await ensureSession() + for attempt in 1...2 { + do { + return try await dispatch( + request, credential: credential, cacheRoot: root, snapshot: snapshot, + session: current, requestID: TransportCrypto.random(count: 16), + inferenceSendCount: inference ? attempt : 0) + } catch TransportError.untrustedRecoveryHint(let code) { + retire(current) + guard attempt == 1, request.allowSessionRecovery, + ![ + "/auth/google/callback", "/auth/github/callback", "/auth/apple/callback", + "/auth/native-handoff/redeem", + ].contains(request.target) + else { + throw TransportError.untrustedRecoveryHint(code) + } + current = try await ensureSession() + guard !inference || attempt < limit else { throw TransportError.untrustedRecoveryHint(code) } + } + } + throw TransportError.invalidSession + } + + private func dispatch( + _ request: MapleRequest, credential: WireCredential?, cacheRoot: Data?, + snapshot: CredentialSnapshot, session: AttestedSession, requestID: Data, + inferenceSendCount: Int + ) async throws -> MapleResponse { + var headers: [String: String] = [:] + for (name, value) in request.headers { + let canonical = name.lowercased() + if canonical == openSecretInferenceSendLimitHeader { continue } + guard headers[canonical] == nil else { throw TransportError.invalidEnvelope } + headers[canonical] = value + } + let envelope = try WireProtocol.envelope( + method: request.method.rawValue, target: request.target, + headers: headers.map { WireHeader(name: $0.key, value: $0.value) }, body: request.body, + credential: credential, cacheRoot: cacheRoot) + let ciphertext = try TransportCrypto.sealRequest(envelope, keys: session.keys, request: requestID) + try await credentials.assertCurrent(snapshot) + try Task.checkCancellation() + guard now() < session.expiresAt else { throw TransportError.expiredSession } + var outer = URLRequest(url: endpoint.outerURL("v2/request")) + outer.httpMethod = "POST"; outer.httpBody = ciphertext + outer.setValue("application/octet-stream", forHTTPHeaderField: "content-type") + outer.setValue(session.routingKey, forHTTPHeaderField: "x-opensecret-routing-key") + outer.setValue(session.identity, forHTTPHeaderField: "x-session-id") + let response = try await network.open(outer) + guard response.status == 200, response.headers["content-type"] == "application/octet-stream" else { + response.cancel() + if response.status == 400, response.headers["x-opensecret-error-contract"] == "1", + let code = response.headers["x-opensecret-error-code"], + ["session_not_found", "request_decryption_failed"].contains(code) + { + throw TransportError.untrustedRecoveryHint(code) + } + throw TransportError.unauthenticatedResponse + } + let reader = ResponseRecordReader(connection: response, keys: session.keys, requestID: requestID) + do { + try await credentials.assertCurrent(snapshot, exactRevision: false) + guard case let .start(status, fields) = try await reader.next() else { throw TransportError.invalidFrame } + var logicalHeaders: [String: String] = [:] + for field in fields { + if let prior = logicalHeaders[field.name] { + logicalHeaders[field.name] = prior + ", " + field.value + } else { + logicalHeaders[field.name] = field.value + } + } + let body = AsyncThrowingStream(unfolding: { [credentials] in + do { + try Task.checkCancellation() + try await credentials.assertCurrent(snapshot, exactRevision: false) + let record = try await reader.next() + try await credentials.assertCurrent(snapshot, exactRevision: false) + switch record { + case let .chunk(bytes): return bytes + case .end: return nil + case let .error(code): throw TransportError.remote(code) + case .start: throw TransportError.invalidFrame + } + } catch { reader.cancel(); throw error } + }) + return MapleResponse( + status: status, headers: logicalHeaders, body: body, inferenceSendCount: inferenceSendCount) + } catch { reader.cancel(); throw error } + } +} diff --git a/sdk/swift/Sources/MapleSDK/Transport/HTTPConnection.swift b/sdk/swift/Sources/MapleSDK/Transport/HTTPConnection.swift new file mode 100644 index 000000000..7dbd0b000 --- /dev/null +++ b/sdk/swift/Sources/MapleSDK/Transport/HTTPConnection.swift @@ -0,0 +1,135 @@ +import Foundation + +struct HTTPConnection: Sendable { + let status: Int + let headers: [String: String] + let read: @Sendable (Int) async throws -> Data + let cancel: @Sendable () -> Void + + func collect(maximum: Int) async throws -> Data { + var result = Data() + do { + while true { + let bytes = try await read(min(4096, maximum + 1 - result.count)) + if bytes.isEmpty { return result } + result.append(bytes) + guard result.count <= maximum else { throw TransportError.resourceLimit } + } + } catch { cancel(); throw error } + } +} + +protocol HTTPNetworking: Sendable { + func open(_ request: URLRequest) async throws -> HTTPConnection +} + +final class URLSessionNetworking: NSObject, HTTPNetworking, URLSessionTaskDelegate, Sendable { + private let session: URLSession + override init() { + let configuration = URLSessionConfiguration.ephemeral + configuration.httpCookieStorage = nil + configuration.httpShouldSetCookies = false + configuration.urlCache = nil + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + configuration.timeoutIntervalForRequest = 60 + configuration.timeoutIntervalForResource = 3600 + session = URLSession(configuration: configuration) + super.init() + } + + func urlSession( + _ session: URLSession, task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, + completionHandler: @escaping @Sendable (URLRequest?) -> Void + ) { completionHandler(nil) } + + func open(_ request: URLRequest) async throws -> HTTPConnection { + let (bytes, response) = try await session.bytes(for: request, delegate: self) + guard let http = response as? HTTPURLResponse, http.url == request.url else { + bytes.task.cancel(); throw TransportError.unauthenticatedResponse + } + var headers: [String: String] = [:] + for (key, value) in http.allHeaderFields { + guard let key = key as? String, let value = value as? String else { continue } + let name = key.lowercased() + if let prior = headers[name] { headers[name] = prior + ", " + value } else { headers[name] = value } + } + let reader = HTTPByteReader(bytes) + return HTTPConnection( + status: http.statusCode, headers: headers, + read: { try await reader.read($0) }, cancel: { bytes.task.cancel() }) + } +} + +/// Pull-based reads preserve backpressure instead of buffering an entire response. +private actor HTTPByteReader { + private var iterator: URLSession.AsyncBytes.AsyncIterator + private let task: URLSessionTask + private var reading = false + init(_ bytes: URLSession.AsyncBytes) { iterator = bytes.makeAsyncIterator(); task = bytes.task } + deinit { task.cancel() } + + func read(_ count: Int) async throws -> Data { + guard count > 0, count <= 65553, !reading else { throw TransportError.invalidFrame } + reading = true + defer { reading = false } + var local = iterator + do { + let result = try await withTaskCancellationHandler { + var data = Data(); data.reserveCapacity(count) + for _ in 0.. WireProtocol.Record { + guard !terminal, !reading else { throw TransportError.postTerminal } + reading = true + defer { reading = false } + do { + let prefix = try await connection.read(4) + guard prefix.count == 4 else { throw TransportError.truncated } + let length = prefix.reduce(0) { $0 * 256 + Int($1) } + guard (16...WireProtocol.maximumCiphertext).contains(length) else { throw TransportError.invalidFrame } + let ciphertext = try await connection.read(length) + guard ciphertext.count == length else { throw TransportError.truncated } + let plaintext = try TransportCrypto.openResponse( + ciphertext, keys: keys, request: requestID, sequence: sequence) + guard sequence < UInt64.max else { throw TransportError.invalidFrame } + sequence += 1 + let record = try WireProtocol.decode(plaintext) + if case .end = record { try await requireEnd() } + if case .error = record { try await requireEnd() } + return record + } catch { connection.cancel(); terminal = true; throw error } + } + + private func requireEnd() async throws { + terminal = true + guard try await connection.read(1).isEmpty else { throw TransportError.postTerminal } + } + nonisolated func cancel() { connection.cancel() } +} diff --git a/sdk/swift/Sources/MapleSDK/Transport/NativeHandoff.swift b/sdk/swift/Sources/MapleSDK/Transport/NativeHandoff.swift new file mode 100644 index 000000000..f6535d994 --- /dev/null +++ b/sdk/swift/Sources/MapleSDK/Transport/NativeHandoff.swift @@ -0,0 +1,15 @@ +import Foundation + +public struct PreparedNativeOAuthHandoff: Sendable { + let id: UUID + public let sessionID: String + public let requestID: String + public let expiresAt: Date +} + +/// Show this account for explicit confirmation before installing its credentials. +public struct NativeSignInConfirmation: Sendable { + let id: UUID + public let principalID: String + public let email: String? +} diff --git a/sdk/swift/Sources/MapleSDK/Transport/TransportCrypto.swift b/sdk/swift/Sources/MapleSDK/Transport/TransportCrypto.swift new file mode 100644 index 000000000..4ff5c4ff5 --- /dev/null +++ b/sdk/swift/Sources/MapleSDK/Transport/TransportCrypto.swift @@ -0,0 +1,106 @@ +import CryptoKit +import Foundation +import Security + +/// Errors deliberately contain no input values, decrypted content, or credentials. +public enum TransportError: Error, Sendable, Equatable { + case invalidEndpoint, invalidEnvelope, invalidSession, expiredSession + case invalidFrame, authentication, truncated, postTerminal, remote(String) + case untrustedRecoveryHint(String), unauthenticatedResponse + case authorityChanged, invalidCredentials, signedOut, persistence, resourceLimit + case requestRejected(status: Int), establishmentTimeout +} + +enum TransportCrypto { + static let prefix = "opensecret/transport-v2/" + + static func random(count: Int) throws -> Data { + guard count > 0, count <= 50 * 1024 * 1024 else { throw TransportError.resourceLimit } + var bytes = Data(count: count) + let result = bytes.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, count, $0.baseAddress!) } + guard result == errSecSuccess else { throw TransportError.authentication } + return bytes + } + + static func domain(_ label: String) -> Data { Data((prefix + label).utf8) + Data([0]) } + + static func derive(shared: Data, challenge: Data, client: Data, server: Data) throws -> SessionKeys { + guard shared.count == 32, shared.contains(where: { $0 != 0 }), + challenge.count == 32, client.count == 32, server.count == 32 + else { + throw TransportError.authentication + } + let digest = Data(SHA256.hash(data: domain("session/v1") + challenge + client + server)) + func key(_ label: String, _ count: Int) -> Data { + HKDF.deriveKey( + inputKeyMaterial: SymmetricKey(data: shared), salt: challenge, + info: domain(label) + digest, outputByteCount: count + ) + .withUnsafeBytes { Data($0) } + } + return SessionKeys( + id: key("session-id/v1", 16), request: key("request-key/v1", 32), + response: key("response-key/v1", 32)) + } + + static func subkey(_ key: Data, label: String, session: Data, request: Data) throws -> SymmetricKey { + guard key.count == 32, session.count == 16, request.count == 16 else { throw TransportError.invalidSession } + let info = domain(label) + session + request + Data([1]) + return SymmetricKey(data: Data(HMAC.authenticationCode(for: info, using: SymmetricKey(data: key)))) + } + + static func sealRequest(_ plaintext: Data, keys: SessionKeys, request: Data) throws -> Data { + let key = try subkey(keys.request, label: "request-subkey/v1", session: keys.id, request: request) + let box = try ChaChaPoly.seal( + plaintext, using: key, nonce: ChaChaPoly.Nonce(data: Data(count: 12)), + authenticating: domain("request-record/v1") + keys.id + request) + return request + box.ciphertext + box.tag + } + + static func openResponse(_ ciphertext: Data, keys: SessionKeys, request: Data, sequence: UInt64) throws -> Data { + guard ciphertext.count >= 16 else { throw TransportError.invalidFrame } + let key = try subkey(keys.response, label: "response-subkey/v1", session: keys.id, request: request) + do { + let box = try ChaChaPoly.SealedBox( + nonce: ChaChaPoly.Nonce(data: Data(count: 4) + sequence.bigEndianData), + ciphertext: ciphertext.dropLast(16), tag: ciphertext.suffix(16)) + return try ChaChaPoly.open( + box, using: key, + authenticating: domain("response-record/v1") + keys.id + request + sequence.bigEndianData) + } catch { throw TransportError.authentication } + } +} + +struct SessionKeys: Sendable, Codable { + let id: Data + let request: Data + let response: Data +} + +extension FixedWidthInteger { + var bigEndianData: Data { + var value = bigEndian + return withUnsafeBytes(of: &value) { Data($0) } + } +} + +extension Data { + var lowercaseHex: String { map { String(format: "%02x", $0) }.joined() } + init?(canonicalBase64 value: String, count: Int) { + guard value.utf8.count <= ((count + 2) / 3) * 4, + let bytes = Data(base64Encoded: value), bytes.count == count, + bytes.base64EncodedString() == value + else { return nil } + self = bytes + } + func constantTimeEquals(_ other: Data) -> Bool { + guard count == other.count else { return false } + return zip(self, other).reduce(UInt8(0)) { $0 | ($1.0 ^ $1.1) } == 0 + } +} + +/// Generates the password-reset ceremony's secure random secret as lowercase hex. +public func generateSecureSecret() throws -> String { try TransportCrypto.random(count: 32).lowercaseHex } + +/// SHA-256 of the UTF-8 secret, matching the other Maple SDKs. +public func hashSecret(_ secret: String) -> String { Data(SHA256.hash(data: Data(secret.utf8))).lowercaseHex } diff --git a/sdk/swift/Sources/MapleSDK/Transport/WireProtocol.swift b/sdk/swift/Sources/MapleSDK/Transport/WireProtocol.swift new file mode 100644 index 000000000..47f88ac1f --- /dev/null +++ b/sdk/swift/Sources/MapleSDK/Transport/WireProtocol.swift @@ -0,0 +1,149 @@ +import Foundation + +struct APIEndpoint: Sendable, Hashable { + let url: URL + let origin: String + let isLoopback: Bool + + init(_ string: String) throws { + guard var components = URLComponents(string: string), let scheme = components.scheme?.lowercased(), + let host = components.host?.lowercased(), !host.isEmpty, + components.user == nil, components.password == nil, components.query == nil, + components.fragment == nil, !string.contains("\\"), + components.port.map({ (1...65535).contains($0) }) ?? true + else { throw TransportError.invalidEndpoint } + isLoopback = scheme == "http" && ["localhost", "127.0.0.1", "[::1]", "::1"].contains(host) + guard scheme == "https" || (scheme == "http" && isLoopback) else { throw TransportError.invalidEndpoint } + components.scheme = scheme + components.host = host + if (scheme == "https" && components.port == 443) || (scheme == "http" && components.port == 80) { + components.port = nil + } + var originComponents = components + originComponents.path = "" + guard let origin = originComponents.string else { throw TransportError.invalidEndpoint } + self.origin = origin + while components.path.hasSuffix("/") { components.path.removeLast() } + guard let url = components.url else { throw TransportError.invalidEndpoint } + self.url = url + } + + func outerURL(_ path: String) -> URL { url.appendingPathComponent(path) } +} + +struct WireCredential: Sendable, Encodable { + enum Kind: String, Sendable, Encodable { case bearer, resumption } + let kind: Kind + let value: String +} + +struct WireHeader: Sendable, Codable, Equatable { + let name: String + let value: String +} + +enum WireProtocol { + static let maximumBody = 50 * 1024 * 1024 + static let maximumCiphertext = 1 + 64 * 1024 + 16 + static let forbiddenHeaders: Set = [ + "authorization", "proxy-authorization", "cookie", "set-cookie", "host", "content-length", + "transfer-encoding", "connection", "keep-alive", "te", "trailer", "upgrade", "forwarded", + "via", "x-forwarded-for", "x-forwarded-host", "x-forwarded-proto", "x-opensecret-routing-key", "x-session-id", + ] + static let tokenCharacters = Set( + "!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz".utf8) + + static func visibleASCII(_ value: String, tabs: Bool = false) -> Bool { + value.utf8.allSatisfy { (32...126).contains($0) || (tabs && $0 == 9) } + } + + static func validateHeaders(_ headers: [WireHeader], maximum: Int) throws { + guard headers.count <= maximum else { throw TransportError.invalidEnvelope } + for header in headers { + guard !header.name.isEmpty, header.name.utf8.allSatisfy({ tokenCharacters.contains($0) }), + header.name == header.name.lowercased(), !forbiddenHeaders.contains(header.name), + visibleASCII(header.value, tabs: true) + else { throw TransportError.invalidEnvelope } + } + } + + static func envelope( + method: String, target: String, headers: [WireHeader], body: Data?, + credential: WireCredential?, cacheRoot: Data? + ) throws -> Data { + guard !method.isEmpty, method.utf8.count <= 32, method.utf8.allSatisfy({ tokenCharacters.contains($0) }), + target.hasPrefix("/"), !target.hasPrefix("//"), !target.contains("#"), !target.contains("\\"), + target.utf8.count <= 16 * 1024, visibleASCII(target), (body?.count ?? 0) <= maximumBody, + cacheRoot == nil || cacheRoot?.count == 32 + else { throw TransportError.invalidEnvelope } + try validateHeaders(headers, maximum: 64) + if let credential { + guard !credential.value.isEmpty, credential.value.utf8.count <= 16 * 1024, + visibleASCII(credential.value), !credential.value.contains(" ") + else { throw TransportError.invalidCredentials } + } + // Explicit nulls and body presence are wire-significant. + let metadata: [String: Any] = [ + "version": 2, + "credential": credential.map { ["kind": $0.kind.rawValue, "value": $0.value] as Any } ?? NSNull(), + "cache_namespace_root": cacheRoot.map { $0.base64EncodedString() as Any } ?? NSNull(), + "method": method, "target": target, + "headers": headers.map { ["name": $0.name, "value": $0.value] }, "body_present": body != nil, + ] + let encoded = try JSONSerialization.data( + withJSONObject: metadata, options: [.sortedKeys, .withoutEscapingSlashes]) + guard encoded.count <= 128 * 1024 else { throw TransportError.invalidEnvelope } + return UInt32(encoded.count).bigEndianData + encoded + (body ?? Data()) + } + + static func exactObject(_ data: Data, keys: Set, maximum: Int = 64 * 1024) throws -> [String: Any] { + guard data.count <= maximum, String(data: data, encoding: .utf8) != nil, + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + Set(object.keys) == keys + else { throw TransportError.invalidEnvelope } + return object + } + + static func integer(_ value: Any?) -> Int? { + guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID(), + number.doubleValue.isFinite, number.doubleValue.rounded() == number.doubleValue, + number.doubleValue >= 0, number.doubleValue <= Double(Int.max - 1024) + else { return nil } + return number.intValue + } + + enum Record: Sendable, Equatable { + case start(Int, [WireHeader]), chunk(Data), end, error(String) + } + + static func decode(_ plaintext: Data) throws -> Record { + guard !plaintext.isEmpty, plaintext.count <= 1 + 64 * 1024 else { throw TransportError.invalidFrame } + let payload = Data(plaintext.dropFirst()) + switch plaintext.first! { + case 1: + let object = try exactObject(payload, keys: ["status", "headers"]) + guard let status = integer(object["status"]), (200...599).contains(status), + let values = object["headers"] as? [[String: Any]] + else { throw TransportError.invalidFrame } + let headers = try values.map { value -> WireHeader in + guard Set(value.keys) == ["name", "value"], let name = value["name"] as? String, + let text = value["value"] as? String + else { throw TransportError.invalidFrame } + return WireHeader(name: name, value: text) + } + try validateHeaders(headers, maximum: 32) + return .start(status, headers) + case 2: return .chunk(payload) + case 3: + guard payload.isEmpty else { throw TransportError.invalidFrame } + return .end + case 4: + let object = try exactObject(payload, keys: ["code"]) + guard let code = object["code"] as? String, !code.isEmpty, code.utf8.count <= 64, + code.utf8.allSatisfy({ (97...122).contains($0) || (48...57).contains($0) || $0 == 95 }) + else { throw TransportError.invalidFrame } + return .error(code) + default: throw TransportError.invalidFrame + } + } +} diff --git a/sdk/swift/Sources/MapleSDK/Trust/AttestationVerifier.swift b/sdk/swift/Sources/MapleSDK/Trust/AttestationVerifier.swift new file mode 100644 index 000000000..2fa9d0783 --- /dev/null +++ b/sdk/swift/Sources/MapleSDK/Trust/AttestationVerifier.swift @@ -0,0 +1,179 @@ +import CryptoKit +import Foundation +import Security + +/// Authenticated Nitro fields. Nitro identity alone is insufficient: the session +/// client must also enforce its PCR policy and bind publicKey/userData to its handshake. +public struct AttestationDocument: Sendable { + public let moduleID: String + public let timestamp: UInt64 + public let digest: String + public let pcrs: [Int: Data] + public let certificate: Data + public let caBundle: [Data] + public let publicKey: Data? + public let userData: Data? + public let nonce: Data? + + init(payload: TrustCBOR) throws { + guard let fields = payload.pairs, fields.allSatisfy({ $0.key.string != nil }), + let moduleID = payload["module_id"]?.string, !moduleID.isEmpty, moduleID.utf8.count <= 1_024, + let timestamp = payload["timestamp"]?.uint, timestamp > 0, timestamp <= 9_007_199_254_740_991, + payload["digest"]?.string == "SHA384", + let rawPCRs = payload["pcrs"]?.pairs, !rawPCRs.isEmpty, rawPCRs.count <= 32, + let certificate = payload["certificate"]?.data, (1...1_024).contains(certificate.count), + let bundle = payload["cabundle"]?.items, (1...16).contains(bundle.count) + else { throw TrustError.malformedDocument } + + var pcrs: [Int: Data] = [:] + for item in rawPCRs { + guard let index = item.key.uint, index < 32, let bytes = item.value.data, + [32, 48, 64].contains(bytes.count) + else { throw TrustError.malformedDocument } + pcrs[Int(index)] = bytes + } + var caBundle: [Data] = [] + for item in bundle { + guard let cert = item.data, (1...1_024).contains(cert.count) else { throw TrustError.malformedDocument } + caBundle.append(cert) + } + self.moduleID = moduleID + self.timestamp = timestamp + digest = "SHA384" + self.pcrs = pcrs + self.certificate = certificate + self.caBundle = caBundle + publicKey = try Self.optionalBytes(payload["public_key"]) + userData = try Self.optionalBytes(payload["user_data"]) + nonce = try Self.optionalBytes(payload["nonce"]) + } + + private static func optionalBytes(_ field: TrustCBOR?) throws -> Data? { + guard let field, field != .null else { return nil } + guard let bytes = field.data, bytes.count <= 1_024 else { throw TrustError.malformedDocument } + return bytes + } + + fileprivate init(loopbackPayload payload: TrustCBOR) throws { + guard payload.pairs != nil else { throw TrustError.malformedDocument } + // Deliberately unauthenticated metadata, never exposed by verify(). + moduleID = "mock-module" + timestamp = 1 + digest = "SHA384" + pcrs = [:] + certificate = Data() + caBundle = [] + publicKey = try Self.optionalBytes(payload["public_key"]) + userData = try Self.optionalBytes(payload["user_data"]) + nonce = try Self.optionalBytes(payload["nonce"]) + } +} + +/// AWS Nitro COSE ES384 verification against the bundled AWS root. This API has +/// no mock switch, caller-supplied trust anchor, network chain fetch, or system-root fallback. +public struct AttestationVerifier: Sendable { + public init() {} + + /// Transport-only local fixture boundary. The transport must select this + /// exclusively after validating its exact HTTP loopback API endpoint, and + /// must still check the nonce, client-key binding and server key length. + /// This is not Nitro verification and is intentionally not public API. + static func parseLoopbackDocument(base64: String) throws -> AttestationDocument { + guard base64.utf8.count <= 90_000, let encoded = Data(base64Encoded: base64), + let envelope = try TrustCBOR.decode(encoded).items, envelope.count == 4, + let payload = envelope[2].data + else { throw TrustError.malformedDocument } + return try AttestationDocument(loopbackPayload: TrustCBOR.decode(payload)) + } + + public func verify(base64: String, nonce: Data, deviceTime: Date = Date()) throws -> AttestationDocument { + guard !base64.isEmpty, base64.utf8.count <= 90_000, + let encoded = Data(base64Encoded: base64), deviceTime.timeIntervalSince1970.isFinite + else { throw TrustError.malformedDocument } + let cose = try COSEDocument(encoded: encoded) + let document = try AttestationDocument(payload: TrustCBOR.decode(cose.payload)) + guard let actualNonce = document.nonce, !nonce.isEmpty, trustEqual(actualNonce, nonce) else { + throw TrustError.nonceMismatch + } + let rootURL = Bundle.module.url(forResource: "aws_nitro_root", withExtension: "der") + guard let rootURL, let trustedRoot = try? Data(contentsOf: rootURL), + trustEqual(document.caBundle[0], trustedRoot) + else { throw TrustError.certificateChain } + + let orderedDER = [document.certificate] + document.caBundle.reversed() + let certificates: [SecCertificate] = try orderedDER.map { + guard let cert = SecCertificateCreateWithData(nil, $0 as CFData) else { throw TrustError.certificateChain } + return cert + } + // Check COSE with the leaf key first so the timestamp is authenticated + // before using it for Security.framework's independent path evaluation. + guard let key = SecCertificateCopyKey(certificates[0]), + let rawKey = SecKeyCopyExternalRepresentation(key, nil) as Data? + else { throw TrustError.certificateChain } + do { + let publicKey = try P384.Signing.PublicKey(x963Representation: rawKey) + let signature = try P384.Signing.ECDSASignature(rawRepresentation: cose.signature) + guard publicKey.isValidSignature(signature, for: cose.signatureStructure) else { + throw TrustError.documentSignature + } + } catch { throw TrustError.documentSignature } + + var trust: SecTrust? + guard + SecTrustCreateWithCertificates(certificates as CFArray, SecPolicyCreateBasicX509(), &trust) + == errSecSuccess, + let trust, + SecTrustSetAnchorCertificates(trust, [certificates.last!] as CFArray) == errSecSuccess, + SecTrustSetAnchorCertificatesOnly(trust, true) == errSecSuccess, + SecTrustSetNetworkFetchAllowed(trust, false) == errSecSuccess + else { throw TrustError.certificateChain } + let enclaveTime = Date(timeIntervalSince1970: Double(document.timestamp) / 1_000) + guard SecTrustSetVerifyDate(trust, enclaveTime as CFDate) == errSecSuccess, + SecTrustEvaluateWithError(trust, nil), + let evaluated = SecTrustCopyCertificateChain(trust) as? [SecCertificate], + evaluated.count == certificates.count + else { throw TrustError.certificateChain } + for (actual, expected) in zip(evaluated, orderedDER) { + guard trustEqual(SecCertificateCopyData(actual) as Data, expected) else { + throw TrustError.certificateChain + } + } + // Every certificate, including the anchor, must be valid on the device. + // The signed enclave timestamp is used only for the clock error diagnosis. + for (index, der) in orderedDER.enumerated() { + let validity = try CertificateValidity(der: der) + guard validity.isValid(at: deviceTime) else { + throw AttestationClockSkewError( + deviceTime: deviceTime, enclaveTime: enclaveTime, + certificateIndex: index, notBefore: validity.notBefore, notAfter: validity.notAfter) + } + } + return document + } +} + +struct COSEDocument { + let protected: Data + let payload: Data + let signature: Data + var signatureStructure: Data { + TrustCBOR.array([.text("Signature1"), .bytes(protected), .bytes(Data()), .bytes(payload)]).encode() + } + + init(encoded: Data) throws { + var decoded = try TrustCBOR.decode(encoded) + if case .tag(let tag, let content) = decoded { + guard tag == 18 else { throw TrustError.malformedDocument } + decoded = content + } + guard let array = decoded.items, array.count == 4, + let protected = array[0].data, array[1].pairs?.isEmpty == true, + let payload = array[2].data, let signature = array[3].data, signature.count == 96 + else { throw TrustError.malformedDocument } + let header = try TrustCBOR.decode(protected) + guard header == .map([.init(.unsigned(1), .negative(-35))]) else { throw TrustError.unsupportedAlgorithm } + self.protected = protected + self.payload = payload + self.signature = signature + } +} diff --git a/sdk/swift/Sources/MapleSDK/Trust/CBOR.swift b/sdk/swift/Sources/MapleSDK/Trust/CBOR.swift new file mode 100644 index 000000000..fdfe224c1 --- /dev/null +++ b/sdk/swift/Sources/MapleSDK/Trust/CBOR.swift @@ -0,0 +1,126 @@ +import Foundation + +/// A deliberately small, bounded CBOR implementation for Nitro's COSE profile. +/// Nitro emits both definite and indefinite containers. Duplicate keys, trailing +/// bytes, unsupported primitive types and unterminated containers are rejected. +indirect enum TrustCBOR: Equatable { + case unsigned(UInt64), negative(Int64), bytes(Data), text(String) + case array([TrustCBOR]), map([Pair]), tag(UInt64, TrustCBOR), null + + struct Pair: Equatable { + let key: TrustCBOR + let value: TrustCBOR + init(_ key: TrustCBOR, _ value: TrustCBOR) { self.key = key; self.value = value } + } + + var data: Data? { if case .bytes(let value) = self { value } else { nil } } + var string: String? { if case .text(let value) = self { value } else { nil } } + var uint: UInt64? { if case .unsigned(let value) = self { value } else { nil } } + var items: [TrustCBOR]? { if case .array(let value) = self { value } else { nil } } + var pairs: [Pair]? { if case .map(let value) = self { value } else { nil } } + subscript(_ key: String) -> TrustCBOR? { pairs?.first { $0.key == .text(key) }?.value } + + static func decode(_ data: Data) throws -> TrustCBOR { + guard !data.isEmpty, data.count <= 65_536 else { throw TrustError.malformedDocument } + var reader = Reader(bytes: Array(data)) + let value = try reader.read(depth: 0) + guard reader.offset == data.count else { throw TrustError.malformedDocument } + return value + } + + func encode() -> Data { + switch self { + case .unsigned(let n): Self.head(0, n) + case .negative(let n): Self.head(1, UInt64(-(n + 1))) + case .bytes(let data): Self.head(2, UInt64(data.count)) + data + case .text(let text): Self.head(3, UInt64(text.utf8.count)) + Data(text.utf8) + case .array(let items): Self.head(4, UInt64(items.count)) + items.reduce(Data()) { $0 + $1.encode() } + case .map(let pairs): + Self.head(5, UInt64(pairs.count)) + pairs.reduce(Data()) { $0 + $1.key.encode() + $1.value.encode() } + case .tag(let tag, let value): Self.head(6, tag) + value.encode() + case .null: Data([0xf6]) + } + } + + private static func head(_ major: UInt8, _ n: UInt64) -> Data { + if n < 24 { return Data([major << 5 | UInt8(n)]) } + let width = n <= 0xff ? 1 : n <= 0xffff ? 2 : n <= 0xffff_ffff ? 4 : 8 + var result = Data([major << 5 | (width == 1 ? 24 : width == 2 ? 25 : width == 4 ? 26 : 27)]) + for i in (0..> (i * 8))) } + return result + } + + private struct Reader { + let bytes: [UInt8] + var offset = 0 + var nodes = 0 + + mutating func take(_ count: Int) throws -> [UInt8] { + guard count >= 0, count <= bytes.count - offset else { throw TrustError.malformedDocument } + defer { offset += count } + return Array(bytes[offset..<(offset + count)]) + } + + mutating func read(depth: Int) throws -> TrustCBOR { + nodes += 1 + guard depth <= 16, nodes <= 1_024 else { throw TrustError.malformedDocument } + let first = try take(1)[0] + if first == 0xf6 { return .null } + let major = first >> 5 + let additional = first & 31 + if additional == 31 { + guard major == 4 || major == 5 else { throw TrustError.malformedDocument } + var items: [TrustCBOR] = [] + var pairs: [Pair] = [] + while true { + guard offset < bytes.count else { throw TrustError.malformedDocument } + if bytes[offset] == 0xff { offset += 1; break } + guard items.count < 128, pairs.count < 128 else { throw TrustError.malformedDocument } + let key = try read(depth: depth + 1) + if major == 4 { + items.append(key) + } else { + guard !pairs.contains(where: { $0.key == key }) else { throw TrustError.malformedDocument } + pairs.append(Pair(key, try read(depth: depth + 1))) + } + } + return major == 4 ? .array(items) : .map(pairs) + } + let value: UInt64 + switch additional { + case 0..<24: value = UInt64(additional) + case 24...27: + value = try take(1 << (additional - 24)).reduce(UInt64(0)) { $0 << 8 | UInt64($1) } + default: throw TrustError.malformedDocument + } + switch major { + case 0: return .unsigned(value) + case 1: + guard value <= UInt64(Int64.max) else { throw TrustError.malformedDocument } + return .negative(-1 - Int64(value)) + case 2, 3: + guard value <= 65_536 else { throw TrustError.malformedDocument } + let data = Data(try take(Int(value))) + if major == 2 { return .bytes(data) } + guard let text = String(data: data, encoding: .utf8) else { throw TrustError.malformedDocument } + return .text(text) + case 4, 5: + guard value <= 128 else { throw TrustError.malformedDocument } + if major == 4 { + var items: [TrustCBOR] = [] + for _ in 0.. validityIndex, tbs[validityIndex].tag == 0x30 else { throw TrustError.certificateChain } + let validity = try tbs[validityIndex].children() + guard validity.count == 2 else { throw TrustError.certificateChain } + notBefore = try Self.date(validity[0]) + notAfter = try Self.date(validity[1]) + guard notBefore <= notAfter else { throw TrustError.certificateChain } + } + + private static func date(_ node: DERNode) throws -> Date { + guard var text = String(data: node.content, encoding: .ascii), text.last == "Z" else { + throw TrustError.certificateChain + } + if node.tag == 0x17, text.count == 13, let year = Int(text.prefix(2)) { + text = (year >= 50 ? "19" : "20") + text + } else if node.tag != 0x18 || text.count != 15 { + throw TrustError.certificateChain + } + guard text.dropLast().utf8.allSatisfy({ (48...57).contains($0) }) else { + throw TrustError.certificateChain + } + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyyMMddHHmmss'Z'" + formatter.isLenient = false + guard let date = formatter.date(from: text), formatter.string(from: date) == text else { + throw TrustError.certificateChain + } + return date + } + + func isValid(at date: Date) -> Bool { + date >= notBefore.addingTimeInterval(-300) && date <= notAfter + } +} + +private struct DERNode { + let tag: UInt8 + let content: Data + + static func parse(_ data: Data) throws -> DERNode { + var reader = DERReader(bytes: Array(data)) + let node = try reader.next() + guard reader.offset == data.count else { throw TrustError.certificateChain } + return node + } + + func children() throws -> [DERNode] { + var reader = DERReader(bytes: Array(content)) + var children: [DERNode] = [] + while reader.offset < content.count { + guard children.count < 64 else { throw TrustError.certificateChain } + children.append(try reader.next()) + } + return children + } +} + +private struct DERReader { + let bytes: [UInt8] + var offset = 0 + + mutating func next() throws -> DERNode { + guard offset + 2 <= bytes.count else { throw TrustError.certificateChain } + let tag = bytes[offset] + let size = bytes[offset + 1] + offset += 2 + // X.509 fields inspected here use one-byte tags and definite DER lengths. + guard tag & 31 != 31 else { throw TrustError.certificateChain } + var length = Int(size) + if size & 0x80 != 0 { + let count = Int(size & 0x7f) + guard (1...4).contains(count), offset + count <= bytes.count, bytes[offset] != 0 else { + throw TrustError.certificateChain + } + length = 0 + for byte in bytes[offset..<(offset + count)] { length = length << 8 | Int(byte) } + offset += count + guard length >= 128 else { throw TrustError.certificateChain } + } + guard length <= bytes.count - offset else { throw TrustError.certificateChain } + defer { offset += length } + return DERNode(tag: tag, content: Data(bytes[offset..<(offset + length)])) + } +} diff --git a/sdk/swift/Sources/MapleSDK/Trust/PCRTrustPolicy.swift b/sdk/swift/Sources/MapleSDK/Trust/PCRTrustPolicy.swift new file mode 100644 index 000000000..9908e7d1b --- /dev/null +++ b/sdk/swift/Sources/MapleSDK/Trust/PCRTrustPolicy.swift @@ -0,0 +1,157 @@ +import CryptoKit +import Foundation + +public enum PCREnvironment: String, Codable, Sendable { + case production, development +} + +/// Immutable environment-scoped PCR0 policy. Additional roots belong to this +/// selected environment only; remote history never falls back to another one. +public struct PCRTrustPolicy: Sendable { + public let environment: PCREnvironment + public let remoteHistoryURL: URL? + let trustedPCR0s: Set + + public init( + environment: PCREnvironment = .production, + additionalPCR0s: [String] = [], + remoteHistoryEnabled: Bool = true, + remoteHistoryURL: URL? = nil + ) throws { + let additional = additionalPCR0s.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + guard additional.allSatisfy({ Self.validHex($0, rejectZero: true) }) else { throw TrustError.invalidPolicy } + self.environment = environment + trustedPCR0s = Set((environment == .development ? Self.developmentRoots : Self.productionRoots) + additional) + let url = remoteHistoryURL ?? Self.historyURL(environment) + try Self.validateURL(url) + self.remoteHistoryURL = remoteHistoryEnabled ? url : nil + } + + /// Cache binding includes every trust-relevant input and the embedded key. + /// Changing policy or SDK roots invalidates persisted attested sessions. + public var cacheIdentity: String { + let components = [ + "swift-pcr0-environment-v1", environment.rawValue, Self.verificationKeyBase64, + trustedPCR0s.sorted().joined(separator: ","), remoteHistoryURL?.absoluteString ?? "disabled", + ] + return SHA256.hash(data: Data(components.joined(separator: "\n").utf8)).map { String(format: "%02x", $0) } + .joined() + } + + /// Must only receive PCR0 from a Nitro-authenticated document. + @discardableResult + public func requireTrustedPCR0(_ bytes: Data?) async throws -> String { + try await requireTrustedPCR0(bytes, fetch: SignedPCRHistory.fetch) + } + + @discardableResult + func requireTrustedPCR0(_ bytes: Data?, fetch: @Sendable (URL) async throws -> Data) async throws -> String { + guard let bytes, bytes.count == 48, bytes.contains(where: { $0 != 0 }) else { throw TrustError.invalidPCR0 } + let hash = bytes.map { String(format: "%02x", $0) }.joined() + if trustedPCR0s.contains(hash) { return hash } + guard let url = remoteHistoryURL else { throw TrustError.untrustedPCR0 } + do { + let history = try await fetch(url) + try Task.checkCancellation() + if try SignedPCRHistory.verify(hash: hash, data: history) { return hash } + } catch is CancellationError { throw CancellationError() } catch + { /* An unavailable or malformed history cannot expand trust. */ } + throw TrustError.untrustedPCR0 + } + + static func validHex(_ value: String, rejectZero: Bool = false) -> Bool { + value.utf8.count == 96 && value.utf8.allSatisfy { (48...57).contains($0) || (97...102).contains($0) } + && (!rejectZero || value.utf8.contains { $0 != 48 }) + } + + static func validateURL(_ url: URL) throws { + guard url.absoluteString.utf8.count <= 2_048, + let parts = URLComponents(url: url, resolvingAgainstBaseURL: false), + let host = parts.host, !host.isEmpty, parts.user == nil, parts.password == nil, parts.fragment == nil, + parts.scheme == "https" + || (parts.scheme == "http" && ["localhost", "127.0.0.1", "[::1]", "::1"].contains(host.lowercased())) + else { throw TrustError.invalidPolicy } + } + + static func historyURL(_ environment: PCREnvironment) -> URL { + URL( + string: "https://raw.githubusercontent.com/MaplePrivacyLabs/Maple/master/services/opensecret/" + + (environment == .development ? "pcrDevHistory.json" : "pcrProdHistory.json"))! + } + + static let verificationKeyBase64 = + "MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEHiUY9kFWK1GqBGzczohhwEwElXzgWLDZa9R6wBx3JOBocgSt9+UIzZlJbPDjYeGBfDUXh7Z62BG2vVsh2NgclLB5S7A2ucBBtb1wd8vSQHP8jpdPhZX1slauPgbnROIP" +} + +enum SignedPCRHistory { + static let maximumBytes = 1_024 * 1_024 + + struct Entry: Decodable { + let PCR0: String + let PCR1: String + let PCR2: String + let timestamp: UInt64 + let signature: String + } + + static func verify(hash: String, data: Data) throws -> Bool { + guard data.count <= maximumBytes else { throw TrustError.historyUnavailable } + let entries: [Entry] + do { entries = try JSONDecoder().decode([Entry].self, from: data) } catch { + throw TrustError.historyUnavailable + } + guard (1...2_048).contains(entries.count), + entries.allSatisfy({ entry in + PCRTrustPolicy.validHex(entry.PCR0) && PCRTrustPolicy.validHex(entry.PCR1) + && PCRTrustPolicy.validHex(entry.PCR2) && entry.timestamp > 0 + && entry.timestamp <= 9_007_199_254_740_991 && entry.signature.utf8.count <= 256 + && Data(base64Encoded: entry.signature)?.count == 96 + }), let der = Data(base64Encoded: PCRTrustPolicy.verificationKeyBase64), + let key = try? P384.Signing.PublicKey(derRepresentation: der) + else { throw TrustError.historyUnavailable } + // The existing protocol signs PCR0 only. Timestamp/PCR1/PCR2 are schema + // validated but never used as authenticated identity or freshness claims. + return entries.contains { entry in + guard entry.PCR0 == hash, let raw = Data(base64Encoded: entry.signature), + let signature = try? P384.Signing.ECDSASignature(rawRepresentation: raw) + else { return false } + return key.isValidSignature(signature, for: Data(entry.PCR0.utf8)) + } + } + + static func fetch(_ url: URL) async throws -> Data { + try await fetch(url, configuration: .ephemeral) + } + + static func fetch(_ url: URL, configuration: URLSessionConfiguration) async throws -> Data { + try PCRTrustPolicy.validateURL(url) + configuration.timeoutIntervalForRequest = 5 + configuration.timeoutIntervalForResource = 5 + configuration.urlCache = nil + configuration.httpCookieStorage = nil + let session = URLSession(configuration: configuration, delegate: RejectHistoryRedirects(), delegateQueue: nil) + defer { session.invalidateAndCancel() } + do { + let (bytes, response) = try await session.bytes(from: url) + guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode), + response.url == url, response.expectedContentLength <= Int64(maximumBytes) + else { throw TrustError.historyUnavailable } + var body = Data() + for try await byte in bytes { + guard body.count < maximumBytes else { throw TrustError.historyUnavailable } + body.append(byte) + } + return body + } catch is CancellationError { throw CancellationError() } catch { throw TrustError.historyUnavailable } + } +} + +private final class RejectHistoryRedirects: NSObject, URLSessionTaskDelegate, Sendable { + func urlSession( + _ session: URLSession, task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, + completionHandler: @escaping @Sendable (URLRequest?) -> Void + ) { + completionHandler(nil) + } +} diff --git a/sdk/swift/Sources/MapleSDK/Trust/PCRTrustRoots.swift b/sdk/swift/Sources/MapleSDK/Trust/PCRTrustRoots.swift new file mode 100644 index 000000000..1a46130f1 --- /dev/null +++ b/sdk/swift/Sources/MapleSDK/Trust/PCRTrustRoots.swift @@ -0,0 +1,17 @@ +// Embedded OpenSecret PCR0 roots shared with the TypeScript and Rust SDKs. +// Changes to approvals belong to the existing signed-PCR review process. +extension PCRTrustPolicy { + static let productionRoots: [String] = [ + // Approved 2026-10-11 (services/opensecret signed history) + "21d27bd19cdd02632485a6e8874753e42cf7f098f1335bb62a9c23e7f64da9100c02dba582da17ac68052fa6be16b7a9", + // Approved 2026-10-09 (services/opensecret signed history; rollback) + "643b3c293a4eac11ec14ab405d2405e46cb714263a12da6c62f485a0fa395cb3c3d5ef07b18d39a1f9715f25e7943b66", + ] + + static let developmentRoots: [String] = [ + // Approved 2026-10-11 (services/opensecret signed history) + "a95956c3fb9457e509be6d614ffefcddf98fe594f77bb6a56891fc1f5bdec41da2add13db22e0908363c8d0929c04def", + // Approved 2026-10-09 (services/opensecret signed history; rollback) + "e573a3ddc8a27f387e24cfb02a8fa594fe782674de6ee1fa9385126b9f8ec38f4a236e91c371bdd2b9444623c1c1cc34", + ] +} diff --git a/sdk/swift/Sources/MapleSDK/Trust/Resources/aws_nitro_root.der b/sdk/swift/Sources/MapleSDK/Trust/Resources/aws_nitro_root.der new file mode 100644 index 000000000..994d128b7 Binary files /dev/null and b/sdk/swift/Sources/MapleSDK/Trust/Resources/aws_nitro_root.der differ diff --git a/sdk/swift/Sources/MapleSDK/Trust/TrustError.swift b/sdk/swift/Sources/MapleSDK/Trust/TrustError.swift new file mode 100644 index 000000000..2018f6832 --- /dev/null +++ b/sdk/swift/Sources/MapleSDK/Trust/TrustError.swift @@ -0,0 +1,107 @@ +import Foundation + +/// Bounded categories suitable for crossing application boundaries. No error contains +/// certificates, URLs, key material, or untrusted response content. +public enum TrustError: Error, Equatable, Sendable, LocalizedError { + case malformedDocument + case nonceMismatch + case unsupportedAlgorithm + case certificateChain + case documentSignature + case invalidPCR0 + case untrustedPCR0 + case invalidPolicy + case historyUnavailable + + public var errorDescription: String? { + switch self { + case .malformedDocument: "The enclave attestation document is invalid." + case .nonceMismatch: "The enclave attestation challenge does not match." + case .unsupportedAlgorithm: "The enclave attestation algorithm is unsupported." + case .certificateChain: "The enclave attestation certificate chain is invalid." + case .documentSignature: "The enclave attestation signature is invalid." + case .invalidPCR0: "The enclave attestation contains an invalid PCR0." + case .untrustedPCR0: "Attestation PCR0 is not in the configured trust roots." + case .invalidPolicy: "The enclave trust policy is invalid." + case .historyUnavailable: "Signed enclave trust history is unavailable." + } + } +} + +/// Matches the TypeScript SDK's certificate-clock diagnosis after authenticating +/// the document. A signed timestamp explains a failure; it never replaces the clock. +public struct AttestationClockSkewError: Error, Sendable, LocalizedError { + public let deviceTime: Date + public let enclaveTime: Date + public let certificateIndex: Int + public let notBefore: Date + public let notAfter: Date + public var skewMs: Double { deviceTime.timeIntervalSince(enclaveTime) * 1_000 } + public var deviceClockLikelyWrong: Bool { abs(skewMs) >= 60_000 } + + public var errorDescription: String? { + let state = deviceTime > notAfter ? "expired" : "not yet valid" + if deviceClockLikelyWrong { + return "This device's clock is about \(Self.duration(abs(skewMs))) " + + "\(skewMs >= 0 ? "ahead of" : "behind") the secure enclave " + + "(device: \(Self.iso(deviceTime)), enclave: \(Self.iso(enclaveTime))), " + + "so the enclave's certificate looks \(state). " + + "Check the device's date, time and time zone settings, then try again." + } + return "The secure enclave's certificate is \(state) on this device's clock " + + "(valid \(Self.iso(notBefore)) to \(Self.iso(notAfter)), " + + "device: \(Self.iso(deviceTime))). Try again in a moment; if it keeps happening, " + + "check the device's date, time and time zone settings." + } + + private static func duration(_ milliseconds: Double) -> String { + // A diagnostic must never trap on an extreme injected system clock. + guard milliseconds.isFinite, milliseconds >= 0, + milliseconds < Double(Int.max) / 2 + else { return "a very large amount of time" } + for (name, size) in [("day", 86_400_000.0), ("hour", 3_600_000), ("minute", 60_000)] { + if milliseconds >= size { + let count = Int((milliseconds / size).rounded()) + return "\(count) \(name)\(count == 1 ? "" : "s")" + } + } + return "\(Int((milliseconds / 1_000).rounded())) seconds" + } + + private static func iso(_ date: Date) -> String { + guard diagnosticDateRange.contains(date.timeIntervalSince1970) else { return "an unrepresentable date" } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: date) + } +} + +extension AttestationClockSkewError { + private static var diagnosticDateRange: ClosedRange { -62_135_596_800...253_402_300_799 } + + /// Constructs a bounded diagnostic for presentation or error propagation. + /// Constructing this value does not verify an attestation or grant trust. + /// Dates must fit the ISO-8601 year range 0001...9999, and certificate + /// validity must be ordered. The verifier creates its error independently. + public init?( + validatingDeviceTime deviceTime: Date, enclaveTime: Date, certificateIndex: Int, + notBefore: Date, notAfter: Date + ) { + let dates = [deviceTime, enclaveTime, notBefore, notAfter] + let delta = deviceTime.timeIntervalSince(enclaveTime) * 1_000 + guard certificateIndex >= 0, notBefore <= notAfter, + dates.allSatisfy({ Self.diagnosticDateRange.contains($0.timeIntervalSince1970) }), + delta.isFinite, abs(delta) < Double(Int.max) / 2 + else { return nil } + self.init( + deviceTime: deviceTime, enclaveTime: enclaveTime, certificateIndex: certificateIndex, + notBefore: notBefore, notAfter: notAfter) + } +} + +func trustEqual(_ left: Data, _ right: Data) -> Bool { + guard left.count == right.count else { return false } + var difference: UInt8 = 0 + for (a, b) in zip(left, right) { difference |= a ^ b } + return difference == 0 +} diff --git a/sdk/swift/Tests/MapleSDKTests/API/LocalIntegrationTests.swift b/sdk/swift/Tests/MapleSDKTests/API/LocalIntegrationTests.swift new file mode 100644 index 000000000..4fb6bfec9 --- /dev/null +++ b/sdk/swift/Tests/MapleSDKTests/API/LocalIntegrationTests.swift @@ -0,0 +1,417 @@ +import Foundation +import Testing +@testable import MapleSDK + +private enum LocalFixtureError: Error { + case missingConfiguration, unsafeEndpoint, mailHelperUnavailable, mailHelperFailed +} + +private struct LocalConfiguration { + let url: String + let email: String + let password: String + let clientId: String + let inviteCode: String + + init() throws { + let env = ProcessInfo.processInfo.environment + guard let url = env["MAPLE_SWIFT_INTEGRATION_URL"], + let parts = URLComponents(string: url), parts.scheme == "http", + ["127.0.0.1", "localhost", "::1", "[::1]"].contains(parts.host ?? ""), + parts.user == nil, parts.password == nil, parts.query == nil, parts.fragment == nil, + parts.path.isEmpty || parts.path == "/" + else { throw LocalFixtureError.unsafeEndpoint } + guard let email = env["MAPLE_SWIFT_TEST_EMAIL"], !email.isEmpty, + let password = env["MAPLE_SWIFT_TEST_PASSWORD"], !password.isEmpty, + let clientId = env["MAPLE_SWIFT_TEST_CLIENT_ID"], UUID(uuidString: clientId) != nil + else { + throw LocalFixtureError.missingConfiguration + } + self.url = url; self.email = email; self.password = password; self.clientId = clientId + inviteCode = env["MAPLE_SWIFT_TEST_INVITE_CODE"] ?? "" + } + + func client() throws -> MapleClient { + try MapleClient( + apiURL: url, clientID: clientId, + policy: PCRTrustPolicy(environment: .development, remoteHistoryEnabled: false), + store: TestSecureStore()) + } + + func signedInEmailClient() async throws -> MapleClient { + let client = try client() + do { _ = try await client.signIn(email: email, password: password) } catch ResearchAPIError.http( + status: 401, reason: _) + { + _ = try await client.signUp(email: email, password: password, inviteCode: inviteCode, name: "SDK fixture") + } + return client + } + + func newGuest() async throws -> (MapleClient, String) { + let client = try client() + let before = try await client.credentials.snapshot() + let login = try await client.anonymousAPI.signUpGuest( + password: password, inviteCode: inviteCode, clientId: clientId) + _ = try await client.install(login, expected: before) + return (client, login.id) + } +} + +@Suite(.serialized, .enabled(if: ProcessInfo.processInfo.environment["MAPLE_SWIFT_INTEGRATION_URL"] != nil)) +struct LocalIntegrationTests { + @Test func localEmailAuthenticationRefreshModelsAndBillingBearer() async throws { + let configuration = try LocalConfiguration() + let client = try await configuration.signedInEmailClient() + let api = try await client.accountAPI() + let user = try await api.getUser() + #expect(user.email == configuration.email) + let before = try await client.credentials.snapshot() + _ = try await client.refreshCredentials() + let after = try await client.credentials.snapshot() + #expect(after.principalId == before.principalId) + #expect(after.revision > before.revision) + #expect(try await api.getUser().id == user.id) + #expect(!(try await api.fetchModels()).isEmpty) + let catalog = try await api.fetchModelCatalog() + #expect(catalog.object == "list") + let token = try await api.generateThirdPartyToken(audience: "https://billing.example.test") + #expect(!token.token.isEmpty) + try await client.signOut() + #expect(try await client.credentials.snapshot().principalId == nil) + await #expect(throws: TransportError.authorityChanged) { try await api.getUser() } + let anonymous = try configuration.client() + #expect(!(try await anonymous.anonymousAPI.fetchModels(authenticated: false)).isEmpty) + } + + @Test func localGuestAuthPasswordAndCompleteCRUD() async throws { + let configuration = try LocalConfiguration() + let (client, userId) = try await configuration.newGuest() + let api = try await client.accountAPI() + #expect(try await api.getUser().id == userId) + let replacementPassword = "SwiftFixture-" + UUID().uuidString + try await client.changePassword(currentPassword: configuration.password, newPassword: replacementPassword) + #expect(try await api.getUser().id == userId) + try await client.signOut() + _ = try await client.signInGuest(id: userId, password: replacementPassword) + let current = try await client.accountAPI() + + let firstProject = try await current.createProject(name: "Swift fixture A") + let secondProject = try await current.createProject(name: "Swift fixture B") + #expect(try await current.getProject(id: firstProject.id).name == "Swift fixture A") + #expect( + try await current.updateProject( + id: firstProject.id, name: "Renamed", instructions: .set("Synthetic instructions") + ).instructions == "Synthetic instructions") + #expect(try await current.updateProject(id: firstProject.id, instructions: .clear).instructions == nil) + #expect(try await current.listProjects(.init(limit: 1, order: .asc)).hasMore) + let projectPage = try await current.listProjects(.init(limit: 1, order: .asc)) + #expect(try await current.listProjects(.init(limit: 1, after: projectPage.lastId, order: .asc)).data.count == 1) + + let first = try await current.createConversation( + metadata: ["title": .string("Swift A")], projectId: firstProject.id, pinned: true) + let second = try await current.createConversation(metadata: ["title": .string("Swift B")]) + let third = try await current.createConversation(metadata: ["title": .string("Swift C")]) + #expect(try await current.getConversation(id: first.id).pinned) + #expect( + try await current.listConversations(.init(projectId: firstProject.id, pinned: true)).data.map(\.id) == [ + first.id + ]) + #expect(try await current.listConversations(.init(unassignedProject: true)).data.count == 2) + let conversationPage = try await current.listConversations(.init(page: .init(limit: 1, order: .desc))) + #expect(conversationPage.hasMore) + #expect( + try await current.listConversations( + .init(page: .init(limit: 1, after: conversationPage.lastId, order: .desc)) + ).data.count == 1) + #expect( + !(try await current.updateConversation( + id: first.id, metadata: ["title": .string("Renamed")], projectId: .clear, pinned: false)).pinned) + #expect( + try await current.batchUpdateConversationProject(ids: [first.id, second.id], projectId: secondProject.id) + .success) + #expect(try await current.getConversation(id: first.id).projectId == secondProject.id) + #expect( + try await current.listConversationItems(conversationId: first.id, parameters: .init(order: .desc)).data + .isEmpty) + await #expect(throws: ResearchAPIError.http(status: 404, reason: .notFound)) { + try await current.getConversationItem(conversationId: first.id, itemId: UUID().uuidString) + } + + let defaultInstruction = try await current.createInstruction( + name: "First", prompt: "Be concise.", isDefault: true) + let otherInstruction = try await current.createInstruction(name: "Second", prompt: "Use prose.") + #expect(try await current.getInstruction(id: defaultInstruction.id).isDefault) + #expect( + try await current.updateInstruction(id: otherInstruction.id, name: "Updated", prompt: "Use plain prose.") + .name == "Updated") + #expect(try await current.setDefaultInstruction(id: otherInstruction.id).isDefault) + #expect(!(try await current.getInstruction(id: defaultInstruction.id)).isDefault) + let instructions = try await current.listInstructions(.init(limit: 1, order: .asc)) + #expect(instructions.hasMore) + #expect( + try await current.listInstructions(.init(limit: 1, after: instructions.lastId, order: .asc)).data.count == 1 + ) + #expect(try await current.deleteInstruction(id: defaultInstruction.id).deleted) + #expect(try await current.deleteInstruction(id: otherInstruction.id).deleted) + + let (foreignClient, _) = try await configuration.newGuest() + let foreign = try await foreignClient.accountAPI() + await #expect(throws: ResearchAPIError.http(status: 404, reason: .notFound)) { + try await foreign.getConversation(id: first.id) + } + await #expect(throws: ResearchAPIError.http(status: 404, reason: .notFound)) { + try await foreign.getProject(id: firstProject.id) + } + let batch = try await current.batchDeleteConversations(ids: [first.id, second.id, UUID().uuidString]) + #expect(batch.data.filter(\.deleted).count == 2) + #expect(batch.data.contains { !$0.deleted && $0.error == "not_found" }) + #expect(try await current.deleteConversation(id: third.id).deleted) + _ = try await current.createConversation() + #expect(try await current.deleteAllConversations().deleted) + #expect(try await current.listConversations().data.isEmpty) + #expect(try await current.deleteProject(id: firstProject.id).deleted) + #expect(try await current.deleteProject(id: secondProject.id).deleted) + try await foreignClient.signOut() + try await client.signOut() + } + + @Test func localAudioResponseAndEmailRejectionContractsWithoutProviderSpending() async throws { + let configuration = try LocalConfiguration() + let client = try await configuration.signedInEmailClient() + let api = try await client.accountAPI() + // Empty audio is rejected before provider dispatch, and blank speech is + // rejected by the existing server input validation. + await #expect(throws: ResearchAPIError.self) { try await api.transcribeAudio(data: Data()) } + await #expect(throws: ResearchAPIError.self) { + try await api.synthesizeSpeech(input: "", voice: "neutral_female") + } + await #expect(throws: ResearchAPIError.http(status: 404, reason: .notFound)) { + try await api.retrieveResponse(id: UUID().uuidString) + } + await #expect(throws: ResearchAPIError.http(status: 404, reason: .notFound)) { + try await api.cancelResponse(id: UUID().uuidString) + } + // Invalid conversation UUID cannot dispatch inference. + await #expect(throws: ResearchAPIError.self) { + try await api.createResponse( + .init(input: .text("Synthetic rejection fixture"), conversation: "invalid-uuid")) + } + await #expect(throws: ResearchAPIError.self) { try await api.verifyEmail(code: UUID().uuidString) } + try await client.signOut() + } + + @Test func localNativeHandoffUsesExactSessionRequestAndAnonymousAuthority() async throws { + let configuration = try LocalConfiguration() + let browser = try await configuration.signedInEmailClient() + let browserAPI = try await browser.accountAPI() + let browserUser = try await browserAPI.getUser() + let app = try configuration.client() + let prepared = try await app.transport.prepareNativeOAuthHandoff() + let grant = try await mintFixtureGrant(browser: browserAPI, prepared: prepared) + let confirmation = try await app.transport.redeemNativeOAuthHandoff(prepared, grant: grant) + #expect(confirmation.principalID == browserUser.id) + #expect(try await app.credentials.snapshot().principalId == nil) + _ = try await app.transport.confirmNativeOAuthHandoff(confirmation) + let accountAPI = try await app.accountAPI() + #expect(try await accountAPI.getUser().id == browserUser.id) + await #expect(throws: TransportError.authorityChanged) { + try await app.transport.confirmNativeOAuthHandoff(confirmation) + } + await #expect(throws: TransportError.self) { + try await app.transport.redeemNativeOAuthHandoff(prepared, grant: grant) + } + await #expect(throws: TransportError.authorityChanged) { + try await app.transport.prepareNativeOAuthHandoff() + } + try await app.signOut() + _ = try await app.signIn(email: configuration.email, password: configuration.password) + await #expect(throws: TransportError.authorityChanged) { try await accountAPI.getUser() } + + let intended = try configuration.client() + let unintended = try configuration.client() + let intendedPreparation = try await intended.transport.prepareNativeOAuthHandoff() + let unintendedPreparation = try await unintended.transport.prepareNativeOAuthHandoff() + let boundGrant = try await mintFixtureGrant(browser: browserAPI, prepared: intendedPreparation) + await #expect(throws: TransportError.self) { + try await unintended.transport.redeemNativeOAuthHandoff(unintendedPreparation, grant: boundGrant) + } + #expect(try await unintended.credentials.snapshot().principalId == nil) + _ = try await intended.signIn(email: configuration.email, password: configuration.password) + await #expect(throws: TransportError.self) { + try await intended.transport.redeemNativeOAuthHandoff(intendedPreparation, grant: boundGrant) + } + + let delayed = try configuration.client() + let delayedPreparation = try await delayed.transport.prepareNativeOAuthHandoff() + let delayedGrant = try await mintFixtureGrant(browser: browserAPI, prepared: delayedPreparation) + let delayedConfirmation = try await delayed.transport.redeemNativeOAuthHandoff( + delayedPreparation, grant: delayedGrant) + _ = try await delayed.signIn(email: configuration.email, password: configuration.password) + await #expect(throws: TransportError.authorityChanged) { + try await delayed.transport.confirmNativeOAuthHandoff(delayedConfirmation) + } + try await delayed.signOut() + try await intended.signOut() + try await app.signOut() + try await browser.signOut() + } + + #if os(macOS) + @Test(.enabled(if: ProcessInfo.processInfo.environment["MAPLE_SWIFT_MAIL_FIXTURE_HELPER"] != nil)) + func localEmailVerificationPasswordResetAndDeletionCeremonies() async throws { + let configuration = try LocalConfiguration() + let client = try configuration.client() + let email = "swift-" + UUID().uuidString.lowercased() + "@example.test" + _ = try await client.signUp( + email: email, password: configuration.password, inviteCode: configuration.inviteCode, + name: "Disposable SDK fixture") + let api = try await client.accountAPI() + try await api.requestNewVerificationEmail() + let verification = try mailCode(kind: "verification", email: email) + try await api.verifyEmail(code: verification) + #expect(try await api.getUser().emailVerified) + let secret = try generateSecureSecret() + try await api.requestPasswordReset( + email: email, hashedSecret: hashSecret(secret), clientId: configuration.clientId) + let reset = try mailCode(kind: "password-reset", email: email) + let replacement = "SwiftReset-" + UUID().uuidString + try await client.signOut() + try await client.anonymousAPI.confirmPasswordReset( + email: email, code: reset, plaintextSecret: secret, newPassword: replacement, + clientId: configuration.clientId) + _ = try await client.signIn(email: email, password: replacement) + let current = try await client.accountAPI() + let deletionSecret = try generateSecureSecret() + try await current.requestAccountDeletion(hashedSecret: hashSecret(deletionSecret)) + let deletion = try mailCode(kind: "account-deletion", email: email) + try await client.confirmAccountDeletion(code: deletion, plaintextSecret: deletionSecret) + #expect(try await client.credentials.snapshot().principalId == nil) + await #expect(throws: ResearchAPIError.self) { + try await client.signIn(email: email, password: replacement) + } + } + #endif + + @Test(.enabled(if: ProcessInfo.processInfo.environment["RUN_LIVE_AI"] == "1"), .timeLimit(.minutes(5))) + func localLiveResponsesAudioAndCancellation() async throws { + let configuration = try LocalConfiguration() + let client = try await configuration.signedInEmailClient() + let api = try await client.accountAPI() + let conversation = try await api.createConversation(metadata: [ + "title": .string("Disposable SDK provider fixture") + ]) + do { + let correlation = UUID().uuidString.lowercased() + let events = try await api.createResponse( + .init( + input: .text("Reply with the word Maple."), conversation: conversation.id, + metadata: ["internal_message_id": .string(correlation)])) + var responseId: String? + var completed = false + for try await event in events { + if case let .created(value) = event { responseId = value.response.id } + if case .completed = event { completed = true } + } + try #require(completed) + guard let responseId else { throw ResearchAPIError.invalidResponse } + #expect(try await api.retrieveResponse(id: responseId).status == .completed) + let items = try await api.listConversationItems( + conversationId: conversation.id, parameters: .init(order: .asc)) + #expect(items.data.contains { $0.id == correlation && $0.role == "user" && $0.responseId == responseId }) + #expect(items.data.contains { $0.role == "assistant" }) + let correlatedItem = try await api.getConversationItem(conversationId: conversation.id, itemId: correlation) + #expect(correlatedItem.id == correlation) + #expect(correlatedItem.type == "message") + #expect(correlatedItem.role == "user") + #expect(correlatedItem.responseId == responseId) + + let speech = try await api.synthesizeSpeech(input: "Maple test.", voice: "neutral_female") + let bytes = try await api.collect(speech.body, limit: 10 * 1_024 * 1_024) + let contentType = + speech.header("content-type")?.split(separator: ";", maxSplits: 1).first? + .trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + let hasAudioContentType = contentType.hasPrefix("audio/") || contentType == "application/octet-stream" + try #require(hasAudioContentType) + let hasWAVHeader = + bytes.count >= 12 && bytes.prefix(4) == Data("RIFF".utf8) + && bytes[8..<12] == Data("WAVE".utf8) + try #require(hasWAVHeader) + let transcript = try await api.transcribeAudio( + data: bytes, filename: "fixture.wav", contentType: "audio/wav", language: "en") + try #require(!transcript.text.isEmpty) + + let cancellable = try await api.createResponse( + .init( + input: .text("Write a numbered list of the integers from one to one hundred."), + conversation: conversation.id)) + var cancelId: String? + var terminal = false + for try await event in cancellable { + if case let .created(value) = event { + cancelId = value.response.id + do { _ = try await api.cancelResponse(id: value.response.id) } catch ResearchAPIError.http( + status: 400, reason: _) + { /* Finished before cancellation: reconcile below. */ } + } + if event.isTerminal { terminal = true } + } + try #require(terminal) + guard let cancelId else { throw ResearchAPIError.invalidResponse } + let status = try await api.retrieveResponse(id: cancelId).status + #expect(status == .cancelled || status == .completed) + } catch { + // Only this test's new conversation is eligible for cleanup. + do { + let deleted = try await api.deleteConversation(id: conversation.id).deleted + #expect(deleted) + } catch { + Issue.record("Could not remove the disposable provider-test conversation after failure.") + } + try? await client.signOut() + throw error + } + #expect(try await api.deleteConversation(id: conversation.id).deleted) + try await client.signOut() + } +} + +#if os(macOS) + /// This opt-in local fixture hook reads only disposable test mail codes. It never + /// invokes a shell, prints captured output, or accepts production integration URLs. + private func mailCode(kind: String, email: String) throws -> String { + guard let path = ProcessInfo.processInfo.environment["MAPLE_SWIFT_MAIL_FIXTURE_HELPER"], path.hasPrefix("/") + else { + throw LocalFixtureError.mailHelperUnavailable + } + let process = Process() + process.executableURL = URL(fileURLWithPath: path) + process.arguments = [kind, email] + let output = Pipe() + process.standardOutput = output + process.standardError = FileHandle.nullDevice + do { try process.run() } catch { throw LocalFixtureError.mailHelperFailed } + let bytes = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + guard process.terminationStatus == 0, bytes.count <= 1_024, + let code = String(data: bytes, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), + !code.isEmpty, code.count <= 128, !code.contains("\n") + else { throw LocalFixtureError.mailHelperFailed } + return code + } +#endif + +/// The hosted broker owns this endpoint. It is intentionally available only to +/// the integration fixture, not added to the native SDK API surface. +private func mintFixtureGrant(browser: ResearchAPI, prepared: PreparedNativeOAuthHandoff) async throws + -> NativeOAuthHandoffGrant +{ + struct GrantResponse: Decodable { let grant: String; let expiresAt: Int64 } + let response: GrantResponse = try await browser.json( + .post, "/auth/native-handoff/grant", + fields: [ + "native_session_id": .string(prepared.sessionID), "native_request_id": .string(prepared.requestID), + ]) + guard response.expiresAt > Int64(Date().timeIntervalSince1970) else { throw ResearchAPIError.invalidResponse } + return try NativeOAuthHandoffGrant(validating: response.grant) +} diff --git a/sdk/swift/Tests/MapleSDKTests/API/OAuthAPITests.swift b/sdk/swift/Tests/MapleSDKTests/API/OAuthAPITests.swift new file mode 100644 index 000000000..96e234c6f --- /dev/null +++ b/sdk/swift/Tests/MapleSDKTests/API/OAuthAPITests.swift @@ -0,0 +1,157 @@ +import Foundation +import Testing +@testable import MapleSDK + +private let oauthLogin = #"{"id":"user-1","access_token":"fake-access-canary","refresh_token":"fake-refresh-canary"}"# + +@Test(arguments: [OAuthProvider.google, .github, .apple]) +func oauthInitiationAndOneUseCallbacks(provider: OAuthProvider) async throws { + let stateKey = provider == .apple ? "state" : "csrf_token" + let transport = APIRecorder([ + "{\"auth_url\":\"https://provider.example.test/authorize\",\"\(stateKey)\":\"state-canary\"}", oauthLogin, + ]) + let api = ResearchAPI(transport: transport) + let now = Date(timeIntervalSince1970: 1_000) + let redirect = try #require(URL(string: "https://AUTH.example.test:443/auth/%63allback?b=2&a=1")) + let attempt = try await api.initiateOAuth( + provider: provider, clientId: "client", inviteCode: "INVITE", redirectURL: redirect, now: now) + #expect(attempt.authorizationURL.host == "provider.example.test") + #expect( + try await attempt.complete(provider: provider, code: "code-canary", state: "state-canary", now: now).id + == "user-1") + await #expect(throws: ResearchAPIError.invalidCallback) { + try await attempt.complete(provider: provider, code: "code-canary", state: "state-canary", now: now) + } + let requests = await transport.snapshot() + #expect(requests.count == 2) + #expect(requests[0].target == "/auth/\(provider.rawValue)") + #expect(requests[1].target == "/auth/\(provider.rawValue)/callback") + #expect(!requests[1].allowSessionRecovery && requests[1].auth == .none) + let fields = try JSONDecoder().decode([String: String].self, from: #require(requests[0].body)) + #expect(fields["redirect_url"] == redirect.absoluteString) + #expect(fields["invite_code"] == "INVITE") +} + +@Test(arguments: ["wrong-state", "wrong-provider", "expired", "future", "cancelled"]) +func oauthRejectionsNeverDispatch(_ rejection: String) async throws { + let transport = APIRecorder([#"{"auth_url":"https://provider.example.test/authorize","csrf_token":"state"}"#]) + let api = ResearchAPI(transport: transport) + let now = Date(timeIntervalSince1970: 1_000) + let attempt = try await api.initiateOAuth(provider: .google, clientId: "client", now: now) + if rejection == "cancelled" { await attempt.cancel() } + let callbackNow = + rejection == "expired" ? now.addingTimeInterval(901) : rejection == "future" ? now.addingTimeInterval(-1) : now + await #expect(throws: ResearchAPIError.self) { + try await attempt.complete( + provider: rejection == "wrong-provider" ? .github : .google, + code: "code", state: rejection == "wrong-state" ? "wrong" : "state", now: callbackNow) + } + #expect(await transport.snapshot().count == 1) + #expect(await transport.cancelledBindings.count == 1) +} + +@Test func nativeApplePassesRawNonceAndOptionalProfileWithoutLogging() async throws { + let transport = APIRecorder([oauthLogin]) + let api = ResearchAPI(transport: transport) + _ = try await api.handleAppleNativeSignIn( + identityToken: "identity-canary", userIdentifier: "apple-sub", rawNonce: "nonce-canary", clientId: "client", + givenName: "Fixture") + let request = try #require(await transport.snapshot().first) + let fields = try JSONDecoder().decode([String: String].self, from: #require(request.body)) + #expect(request.target == "/auth/apple/native" && request.auth == .none) + #expect(fields["nonce"] == "nonce-canary") + #expect(fields["given_name"] == "Fixture") + #expect(fields["email"] == nil && fields["invite_code"] == nil) + await #expect(throws: ResearchAPIError.invalidArgument) { + try await api.handleAppleNativeSignIn( + identityToken: "token", userIdentifier: "sub", rawNonce: "", clientId: "client") + } + #expect(await transport.snapshot().count == 1) +} + +@Test func secureSecretAndHashMatchReference() throws { + let secret = try generateSecureSecret() + #expect(secret.count == 64) + #expect(secret.allSatisfy { "0123456789abcdef".contains($0) }) + #expect(secret != (try generateSecureSecret())) + #expect(hashSecret("abc") == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") +} + +@Test func nativeHandoffEnforcesCanonicalGrantAndExactURL() throws { + let grant = "eyJhbGciOiJFUzI1NksifQ.eyJzdWIiOiJ1c2VyIn0.c2lnbmF0dXJl" + let valid = try NativeOAuthHandoffGrant( + callbackURL: #require(URL(string: "cloud.opensecret.maple.dev://auth?handoff_grant=\(grant)")), + expectedScheme: "cloud.opensecret.maple.dev") + #expect(valid.rawValue == grant) + #expect(!String(describing: valid).contains(grant)) + #expect(!String(reflecting: valid).contains(grant)) + let badURLs = [ + "cloud.opensecret.maple://auth?handoff_grant=\(grant)", + "cloud.opensecret.maple.dev://evil?handoff_grant=\(grant)", + "cloud.opensecret.maple.dev://auth/other?handoff_grant=\(grant)", + "cloud.opensecret.maple.dev://user@auth?handoff_grant=\(grant)", + "cloud.opensecret.maple.dev://auth:1?handoff_grant=\(grant)", + "cloud.opensecret.maple.dev://auth?handoff_grant=\(grant)#fragment", + "cloud.opensecret.maple.dev://auth?handoff_grant=\(grant)&handoff_grant=\(grant)", + "cloud.opensecret.maple.dev://auth?handoff_grant=\(grant)&state=x", + "cloud.opensecret.maple.dev://auth?token=\(grant)", + ] + for url in badURLs { + #expect(throws: ResearchAPIError.invalidCallback) { + try NativeOAuthHandoffGrant( + callbackURL: #require(URL(string: url)), expectedScheme: "cloud.opensecret.maple.dev") + } + } + for bad in ["", "a.b.c", "YQ.Yg", "YQ.Yg.Yw.extra", "YQ=.Yg.Yw", "YR.Yg.Yw", String(repeating: "x", count: 4097)] { + #expect(throws: ResearchAPIError.invalidCallback) { try NativeOAuthHandoffGrant(validating: bad) } + } +} + +@Test func cancelledAndRejectedOAuthAttemptsReleaseTheirTransportBinding() async throws { + let success = #"{"auth_url":"https://provider.example.test/authorize","csrf_token":"state"}"# + let recorder = APIRecorder(Array(repeating: success, count: 20)) + let api = ResearchAPI(transport: recorder) + for _ in 0..<20 { + let attempt = try await api.initiateOAuth(provider: .google, clientId: "client") + await attempt.cancel() + } + #expect(await recorder.cancelledBindings.count == 20) + + for reply in [ + APIRecorder.Reply(body: "invalid"), + .init(body: #"{"auth_url":"http://provider.example.test","csrf_token":"state"}"#), + .init(status: 403, body: "forbidden"), + ] { + let recorder = APIRecorder([reply]) + let api = ResearchAPI(transport: recorder) + await #expect(throws: ResearchAPIError.self) { + try await api.initiateOAuth(provider: .google, clientId: "client") + } + #expect(await recorder.cancelledBindings.count == 1) + } +} + +@Test func rejectedOAuthInitiationDoesNotExhaustRealTransportCapacity() async throws { + let store = TestSecureStore() + let credentials = try CredentialAuthority(apiURL: "http://127.0.0.1:3000", store: store) + let network = FixtureNetwork() + let transport = try EncryptedTransport( + apiURL: "http://127.0.0.1:3000", policy: PCRTrustPolicy(environment: .development), credentials: credentials, + store: store, network: network, now: Date.init) + let api = ResearchAPI(transport: transport) + // FixtureNetwork returns authenticated JSON without the OAuth fields. Every + // initiation must release its retained handle after decoding fails. + for _ in 0..<20 { + await #expect(throws: ResearchAPIError.invalidResponse) { + try await api.initiateOAuth(provider: .google, clientId: "client") + } + } + for _ in 0..<20 { + let (response, binding) = try await transport.beginOAuth( + MapleRequest(method: .post, target: "/auth/google", auth: .none)) + _ = try await api.collect(response.body) + await transport.cancelOAuth(binding: binding) + } + #expect(await network.observations.count == 40) + #expect(await network.handshakes == 1) +} diff --git a/sdk/swift/Tests/MapleSDKTests/API/ResearchAPITests.swift b/sdk/swift/Tests/MapleSDKTests/API/ResearchAPITests.swift new file mode 100644 index 000000000..61b5aef7a --- /dev/null +++ b/sdk/swift/Tests/MapleSDKTests/API/ResearchAPITests.swift @@ -0,0 +1,514 @@ +import Foundation +import Testing +@testable import MapleSDK + +actor APIRecorder: OAuthSessionTransport { + struct Reply: Sendable { + var status = 200 + var body: String + var headers: [String: String] = [:] + var sends = 1 + } + var requests: [MapleRequest] = [] + var replies: [Reply] + var bindings: [OAuthSessionBinding] = [] + var cancelledBindings: [OAuthSessionBinding] = [] + init(_ replies: [Reply]) { self.replies = replies } + init(_ bodies: [String]) { self.replies = bodies.map { Reply(body: $0) } } + func send(_ request: MapleRequest) throws -> MapleResponse { + requests.append(request) + guard !replies.isEmpty else { throw ResearchAPIError.invalidResponse } + let reply = replies.removeFirst() + return MapleResponse( + status: reply.status, headers: reply.headers, + body: AsyncThrowingStream { + $0.yield(Data(reply.body.utf8)); $0.finish() + }, inferenceSendCount: reply.sends) + } + func beginOAuth(_ request: MapleRequest) throws -> (MapleResponse, OAuthSessionBinding) { + (try send(request), OAuthSessionBinding()) + } + func completeOAuth(_ request: MapleRequest, binding: OAuthSessionBinding) throws -> MapleResponse { + bindings.append(binding) + return try send(request) + } + func cancelOAuth(binding: OAuthSessionBinding) { cancelledBindings.append(binding) } + func snapshot() -> [MapleRequest] { requests } +} + +private let login = + #"{"id":"user-1","email":"fixture@example.test","access_token":"fake-access-canary","refresh_token":"fake-refresh-canary"}"# +private let conversation = + #"{"id":"conversation-1","object":"conversation","created_at":1,"metadata":{"title":"Title"},"project_id":null,"pinned":false,"last_activity_at":2}"# +private let project = + #"{"id":"project-1","object":"conversation.project","name":"Project","instructions":null,"created_at":1,"updated_at":2}"# +private let instruction = + #"{"id":"instruction-1","object":"instruction","name":"Default","prompt":"Help","prompt_tokens":1,"is_default":true,"created_at":1,"updated_at":2}"# +private let deleted = #"{"id":"object-1","object":"conversation.deleted","deleted":true}"# +private let page = #"{"object":"list","data":[],"first_id":null,"last_id":null,"has_more":false}"# + +private func object(_ request: MapleRequest) throws -> [String: JSONValue] { + try JSONDecoder().decode([String: JSONValue].self, from: #require(request.body)) +} + +@Test func authLoginAndRegistrationWireParity() async throws { + let transport = APIRecorder([login, login, login, login]) + let api = ResearchAPI(transport: transport) + #expect(try await api.signIn(email: "fixture@example.test", password: "canary", clientId: "client").id == "user-1") + _ = try await api.signInGuest(id: "guest", password: "canary", clientId: "client") + _ = try await api.signUp( + email: "fixture@example.test", password: "canary", inviteCode: "INVITE", clientId: "client", name: "Fixture") + _ = try await api.signUpGuest(password: "canary", inviteCode: "INVITE", clientId: "client") + let requests = await transport.snapshot() + #expect(requests.map(\.target) == ["/login", "/login", "/register", "/register"]) + #expect(requests.allSatisfy { $0.auth == .none && $0.method == .post }) + #expect( + try object(requests[0]) == [ + "email": .string("fixture@example.test"), "password": .string("canary"), "client_id": .string("client"), + ]) + #expect(try object(requests[1])["id"] == .string("guest")) + #expect(try object(requests[2])["inviteCode"] == .string("invite")) + #expect(try object(requests[2])["name"] == .string("Fixture")) + #expect(try object(requests[3])["email"] == nil) +} + +@Test func accountFlowParityAndOwnership() async throws { + let user = + #"{"user":{"id":"user-1","name":null,"email":"fixture@example.test","email_verified":true,"login_method":"email","created_at":"now","updated_at":"now"}}"# + let transport = APIRecorder([ + user, "", "", "", "", "", #"{"message":"changed","access_token":"new-a","refresh_token":"new-r"}"#, "", "", + #"{"token":"billing-canary"}"#, + ]) + let api = ResearchAPI(transport: transport, expectedPrincipalId: "user-1") + #expect(try await api.getUser().emailVerified) + try await api.signOut(refreshToken: "refresh-canary") + try await api.verifyEmail(code: "a/b?c") + try await api.requestNewVerificationEmail() + try await api.requestPasswordReset(email: "fixture@example.test", hashedSecret: "hash", clientId: "client") + try await api.confirmPasswordReset( + email: "fixture@example.test", code: "code", plaintextSecret: "plain", newPassword: "new", clientId: "client") + #expect(try await api.changePassword(currentPassword: "old", newPassword: "new").accessToken == "new-a") + try await api.requestAccountDeletion(hashedSecret: "hash") + try await api.confirmAccountDeletion(code: "code", plaintextSecret: "plain") + _ = try await api.generateThirdPartyToken(audience: "https://billing.example.test") + let requests = await transport.snapshot() + #expect( + requests.map(\.target) == [ + "/protected/user", "/logout", "/verify-email/a%2Fb%3Fc", "/protected/request_verification", + "/password-reset/request", "/password-reset/confirm", "/protected/change_password", + "/protected/delete-account/request", "/protected/delete-account/confirm", "/protected/third_party_token", + ]) + #expect(requests.allSatisfy { $0.expectedPrincipalId == "user-1" }) + #expect(requests[0].body == nil) + #expect(try object(requests[5])["alphanumeric_code"] == .string("code")) + #expect(try object(requests[8])["plaintext_secret"] == .string("plain")) +} + +@Test func credentialPartialUpdateAndWrongUserAreRejected() async throws { + let transport = APIRecorder([ + #"{"message":"changed","access_token":"incomplete"}"#, + #"{"user":{"id":"wrong","name":null,"email_verified":true,"login_method":"email","created_at":"now","updated_at":"now"}}"#, + ]) + let api = ResearchAPI(transport: transport, expectedPrincipalId: "expected") + await #expect(throws: ResearchAPIError.invalidResponse) { + try await api.changePassword(currentPassword: "old", newPassword: "new") + } + await #expect(throws: ResearchAPIError.invalidResponse) { try await api.getUser() } +} + +@Test func conversationCRUDPreservesNullFalseAndPagination() async throws { + let transport = APIRecorder([ + conversation, conversation, conversation, page, deleted, + #"{"object":"list.deleted","deleted":true}"#, + #"{"object":"list","data":[{"id":"a","object":"conversation.deleted","deleted":false,"error":"not_found"}]}"#, + #"{"success":true}"#, page, + #"{"id":"item-1","response_id":"response-1","type":"reasoning","status":"completed","content":[{"type":"reasoning_text","text":"Thought"}]}"#, + ]) + let api = ResearchAPI(transport: transport, expectedPrincipalId: "owner") + _ = try await api.createConversation(metadata: ["title": .string("Title")], projectId: "p", pinned: false) + _ = try await api.getConversation(id: "a/b") + _ = try await api.updateConversation(id: "a", projectId: .clear, pinned: false) + _ = try await api.listConversations( + .init(page: .init(limit: 20, after: "a+b&c", order: .desc), unassignedProject: true, pinned: false)) + _ = try await api.deleteConversation(id: "a") + _ = try await api.deleteAllConversations() + #expect(try await api.batchDeleteConversations(ids: ["a"]).data.first?.error == "not_found") + _ = try await api.batchUpdateConversationProject(ids: ["a"], projectId: nil) + _ = try await api.listConversationItems( + conversationId: "a", parameters: .init(limit: 20, after: "cursor", order: .desc)) + #expect(try await api.getConversationItem(conversationId: "a", itemId: "item").type == "reasoning") + let requests = await transport.snapshot() + #expect(requests[1].target == "/v1/conversations/a%2Fb") + #expect(try object(requests[2]) == ["project_id": .null, "pinned": .bool(false)]) + #expect( + requests[3].target + == "/v1/conversations?limit=20&after=a%2Bb%26c&order=desc&unassigned_project=true&pinned=false") + #expect(requests[4].method == .delete && requests[5].method == .delete) + #expect(try object(requests[7])["project_id"] == .null) + #expect(requests[8].target == "/v1/conversations/a/items?limit=20&after=cursor&order=desc") + #expect(requests[9].target == "/v1/conversations/a/items/item") + #expect(requests.allSatisfy { $0.auth == .required && $0.expectedPrincipalId == "owner" }) +} + +@Test func projectAndInstructionParity() async throws { + let transport = APIRecorder([ + project, page, project, project, deleted, instruction, page, instruction, instruction, deleted, instruction, + ]) + let api = ResearchAPI(transport: transport) + _ = try await api.createProject(name: "Project") + _ = try await api.listProjects(.init(limit: 10, order: .asc)) + _ = try await api.getProject(id: "p") + _ = try await api.updateProject(id: "p", instructions: .clear) + _ = try await api.deleteProject(id: "p") + _ = try await api.createInstruction(name: "Default", prompt: "Help", isDefault: false) + _ = try await api.listInstructions(.init(after: "cursor")) + _ = try await api.getInstruction(id: "i") + _ = try await api.updateInstruction(id: "i", prompt: "Changed", isDefault: false) + _ = try await api.deleteInstruction(id: "i") + _ = try await api.setDefaultInstruction(id: "i") + let requests = await transport.snapshot() + #expect( + requests.map(\.target) == [ + "/v1/conversation-projects", "/v1/conversation-projects?limit=10&order=asc", "/v1/conversation-projects/p", + "/v1/conversation-projects/p", "/v1/conversation-projects/p", "/v1/instructions", + "/v1/instructions?after=cursor", "/v1/instructions/i", "/v1/instructions/i", "/v1/instructions/i", + "/v1/instructions/i/set-default", + ]) + #expect(try object(requests[3]) == ["instructions": .null]) + #expect(try object(requests[5])["is_default"] == .bool(false)) + #expect(try object(requests[8])["is_default"] == .bool(false)) + #expect(requests[10].body == nil) + await #expect(throws: ResearchAPIError.invalidArgument) { try await api.updateProject(id: "p") } + await #expect(throws: ResearchAPIError.invalidArgument) { try await api.updateConversation(id: "c") } + await #expect(throws: ResearchAPIError.invalidArgument) { + try await api.listConversations(.init(page: .init(limit: 101))) + } +} + +@Test func modelAndAudioContracts() async throws { + let models = #"{"object":"list","data":[{"id":"model","object":"model","created":0,"owned_by":"opensecret"}]}"# + let catalog = + #"{"object":"list","data":[],"aliases":[],"defaults":{"quick":"auto:quick","powerful":"auto:powerful"},"audio":{"speech":{"available":true,"model":"voxtral-tts"}}}"# + let transport = APIRecorder([models, models, catalog, #"{"text":"Transcript"}"#, "wav"]) + let api = ResearchAPI(transport: transport) + #expect(try await api.fetchModels(authenticated: false).first?.ownedBy == "opensecret") + _ = try await api.fetchModels() + #expect(try await api.fetchModelCatalog().defaults.powerful == .powerful) + #expect( + try await api.transcribeAudio( + data: Data([0, 1, 2]), filename: "fixture.wav", contentType: "audio/wav", language: "en", temperature: 0 + ).text == "Transcript") + let speech = try await api.synthesizeSpeech(input: "Hello", voice: "voice", speed: 1.5) + #expect(String(data: try await api.collect(speech.body), encoding: .utf8) == "wav") + let requests = await transport.snapshot() + #expect(requests[0].auth == .none && requests[1].auth == .required) + #expect( + requests.map(\.target) == [ + "/v1/models", "/v1/models", "/v1/models/catalog", "/v1/audio/transcriptions", "/v1/audio/speech", + ]) + #expect(try object(requests[3])["file"] == .string("AAEC")) + #expect(try object(requests[3])["model"] == .string("whisper-large-v3")) + #expect(try object(requests[4])["response_format"] == .string("wav")) +} + +@Test(arguments: ["missing", "null", "published", "future"]) +func catalogReasoningAndCompletionBudgetRemainBackwardCompatible(kind: String) async throws { + var item: [String: Any] = [ + "id": "model", "object": "model", "created": 0, "owned_by": "fixture", + "display_name": "Model", "short_name": "Model", "context_window": 4096, + "max_context_tokens": 4096, "access": "free", "enabled": true, "deprecated": false, + "capabilities": ["chat": true, "vision": false, "reasoning": true, "tool_use": true], + ] + var alias: [String: Any] = [ + "id": "auto:quick", "label": "Quick", "short_name": "Quick", "description": "Fast", + "target_model": "model", "access": "free", "capabilities": item["capabilities"]!, + ] + if kind == "null" { + item["reasoning"] = NSNull(); alias["reasoning"] = NSNull() + item["max_completion_tokens"] = NSNull(); alias["max_completion_tokens"] = NSNull() + } else if kind != "missing" { + let effort = kind == "future" ? "future-effort" : "high" + let reasoning: [String: Any] = [ + "mandatory": true, "default_enabled": true, "supported_efforts": [effort, "low"], + "default_effort": effort, + ] + item["reasoning"] = reasoning; alias["reasoning"] = reasoning + item["max_completion_tokens"] = 1024; alias["max_completion_tokens"] = 2048 + } + let data = try JSONSerialization.data(withJSONObject: [ + "object": "list", "data": [item], "aliases": [alias], + "defaults": ["quick": "auto:quick", "powerful": "auto:powerful"], + ]) + let api = ResearchAPI(transport: APIRecorder([String(decoding: data, as: UTF8.self)])) + let catalog = try await api.fetchModelCatalog() + let decodedItem = try #require(catalog.data.first), decodedAlias = try #require(catalog.aliases.first) + if kind == "missing" || kind == "null" { + #expect(decodedItem.reasoning == nil && decodedAlias.reasoning == nil) + #expect(decodedItem.maxCompletionTokens == nil && decodedAlias.maxCompletionTokens == nil) + } else { + let effort = kind == "future" ? "future-effort" : "high" + #expect(decodedItem.reasoning?.mandatory == true && decodedItem.reasoning?.defaultEnabled == true) + #expect(decodedItem.reasoning?.supportedEfforts == [effort, "low"]) + #expect(decodedItem.reasoning?.defaultEffort == effort && decodedAlias.reasoning == decodedItem.reasoning) + #expect(decodedItem.maxCompletionTokens == 1024 && decodedAlias.maxCompletionTokens == 2048) + } +} + +@Test func errorsAreSanitizedBoundedAndNeverRetried() async throws { + let transport = APIRecorder([ + .init( + status: 403, body: #"{"error":{"code":"usage_limit_reached","message":"private-canary"}}"#, + headers: ["x-opensecret-error-contract": "1", "x-opensecret-error-code": "usage_limit_reached"]) + ]) + let api = ResearchAPI(transport: transport) + do { _ = try await api.getConversation(id: "c"); Issue.record("Expected error") } catch { + #expect(error as? ResearchAPIError == .http(status: 403, reason: .usageLimit)) + #expect(!String(describing: error).contains("private-canary")) + } + #expect(await transport.snapshot().count == 1) + let oversized = ResearchAPI(transport: APIRecorder([conversation]), maximumResponseBytes: 5) + await #expect(throws: ResearchAPIError.responseTooLarge) { try await oversized.getConversation(id: "c") } +} + +@Test func chatErrorCodesUseEverySuppliedSourceAndTheHTTPStatus() async throws { + let cases: [(Int, String, ResearchAPIError.RejectionReason, ResearchAPIError.RejectionReason)] = [ + (403, "usage_limit_reached", .usageLimit, .forbidden), + (403, "free_tier_token_limit_exceeded", .freeTierTokenLimit, .forbidden), + (413, "message_exceeds_context_limit", .contextLimit, .payloadTooLarge), + (403, "model_not_available_on_plan", .modelUnavailable, .forbidden), + (400, "unsupported_value", .unsupportedValue, .unspecified), + ] + for (status, code, reason, fallback) in cases { + let headers = ["x-opensecret-error-contract": "1", "x-opensecret-error-code": code] + let body = String( + data: try JSONEncoder().encode([ + "code": JSONValue.string(code), + "error": .object(["code": .string(code), "message": .string("Changed public wording")]), + ]), encoding: .utf8)! + for reply in [ + APIRecorder.Reply(status: status, body: body, headers: headers), + APIRecorder.Reply(status: status, body: "", headers: headers), + APIRecorder.Reply(status: status, body: body), + ] { + let api = ResearchAPI(transport: APIRecorder([reply])) + await #expect(throws: ResearchAPIError.http(status: status, reason: reason)) { + try await api.getConversation(id: "c") + } + } + // The same code under a different HTTP status cannot establish the issue. + let wrongStatus = status == 403 ? 413 : 403 + let wrongReason: ResearchAPIError.RejectionReason = wrongStatus == 413 ? .payloadTooLarge : .forbidden + let wrongAPI = ResearchAPI( + transport: APIRecorder([.init(status: wrongStatus, body: body, headers: headers)])) + await #expect(throws: ResearchAPIError.http(status: wrongStatus, reason: wrongReason)) { + try await wrongAPI.getConversation(id: "c") + } + let unversionedAPI = ResearchAPI( + transport: APIRecorder([ + .init(status: status, body: body, headers: ["x-opensecret-error-code": code]) + ])) + await #expect(throws: ResearchAPIError.http(status: status, reason: fallback)) { + try await unversionedAPI.getConversation(id: "c") + } + // A nested body code alone is sufficient, with or without contract 1. + let nestedBody = String( + data: try JSONEncoder().encode(["error": ["code": code]]), encoding: .utf8)! + for nestedHeaders in [[:], ["x-opensecret-error-contract": "1"]] { + let nestedAPI = ResearchAPI( + transport: APIRecorder([.init(status: status, body: nestedBody, headers: nestedHeaders)])) + await #expect(throws: ResearchAPIError.http(status: status, reason: reason)) { + try await nestedAPI.getConversation(id: "c") + } + } + } +} + +@Test func conflictingUnknownAndMalformedChatCodesNeverUseLegacyMessages() async throws { + let legacy = #"{"status":403,"message":"Usage limit reached"}"# + let cases: [APIRecorder.Reply] = [ + .init( + status: 403, body: legacy, + headers: ["x-opensecret-error-contract": "2", "x-opensecret-error-code": "usage_limit_reached"]), + .init(status: 403, body: legacy, headers: ["x-opensecret-error-contract": "2"]), + .init(status: 403, body: legacy, headers: ["x-opensecret-error-code": "usage_limit_reached"]), + .init( + status: 403, body: legacy, + headers: ["x-opensecret-error-contract": "1", "x-opensecret-error-code": "unknown"]), + .init(status: 403, body: #"{"status":403,"message":"Usage limit reached","code":"unknown"}"#), + .init(status: 403, body: #"{"status":403,"message":"Usage limit reached","code":403}"#), + .init(status: 403, body: #"{"status":403,"message":"Usage limit reached","error":{"code":false}}"#), + .init( + status: 403, + body: + #"{"status":403,"message":"Usage limit reached","code":"usage_limit_reached","error":{"code":"free_tier_token_limit_exceeded"}}"# + ), + .init( + status: 403, + body: #"{"status":403,"message":"Usage limit reached","error":{"code":"model_not_available_on_plan"}}"#, + headers: ["x-opensecret-error-contract": "1", "x-opensecret-error-code": "usage_limit_reached"]), + .init( + status: 403, body: #"{"error":{"code":"model_not_available_on_plan"},"extra":1e400}"#, + headers: ["x-opensecret-error-contract": "1", "x-opensecret-error-code": "usage_limit_reached"]), + .init( + status: 403, body: #"{"error":{"code":1e400}}"#, + headers: ["x-opensecret-error-contract": "1", "x-opensecret-error-code": "usage_limit_reached"]), + ] + for reply in cases { + let api = ResearchAPI(transport: APIRecorder([reply])) + await #expect(throws: ResearchAPIError.http(status: 403, reason: .forbidden)) { + try await api.getConversation(id: "c") + } + } +} + +@Test func legacyChatLimitsRequireExactBoundedMessagesAndMatchingStatuses() async throws { + let cases: [(Int, String, ResearchAPIError.RejectionReason)] = [ + (403, "Usage limit reached", .usageLimit), + (403, "Free tier token limit exceeded", .freeTierTokenLimit), + (413, "Message exceeds context limit", .contextLimit), + (403, "Model not available on current plan", .modelUnavailable), + ] + for (status, message, reason) in cases { + let body = String( + data: try JSONEncoder().encode(["status": JSONValue.integer(Int64(status)), "message": .string(message)]), + encoding: .utf8)! + let api = ResearchAPI(transport: APIRecorder([.init(status: status, body: body)])) + await #expect(throws: ResearchAPIError.http(status: status, reason: reason)) { + try await api.getConversation(id: "c") + } + } + for body in [ + "Usage limit reached", + #"{"error":"Usage limit reached"}"#, + #"{"message":"Usage limit reached"}"#, + #"{"status":"403","message":"Usage limit reached"}"#, + #"{"status":413,"message":"Usage limit reached"}"#, + #"{"status":403,"message":"Usage limit reached soon"}"#, + #"{"status":403,"message":"Usage limit reached""#, + #"{"status":403,"message":"Usage limit reached"} trailing"#, + #"{"status":403,"message":"Usage limit reached","padding":""# + String(repeating: "x", count: 4_096) + #""}"#, + ] { + let api = ResearchAPI(transport: APIRecorder([.init(status: 403, body: body)])) + await #expect(throws: ResearchAPIError.http(status: 403, reason: .forbidden)) { + try await api.getConversation(id: "c") + } + } +} + +@Test func chatErrorHeadersRemainUntrustedUntilTheBodyFinishes() async throws { + let api = ResearchAPI(transport: APIRecorder([String]())) + let response = MapleResponse( + status: 403, + headers: ["x-opensecret-error-contract": "1", "x-opensecret-error-code": "usage_limit_reached"], + body: AsyncThrowingStream { + $0.yield(Data(#"{"error":{"code":"usage_limit_reached"}}"#.utf8)) + $0.finish(throwing: ResearchAPIError.invalidStream) + }) + await #expect(throws: ResearchAPIError.invalidStream) { try await api.checked(response) } +} + +@Test func legacyModelPlanDenialKeepsStructuredCodePrecedence() async throws { + let message = "Model not available on current plan" + let legacy = #"{"status":403,"message":"Model not available on current plan"}"# + let cases: [(String, [String: String], ResearchAPIError.RejectionReason)] = [ + (legacy, [:], .modelUnavailable), + (#"{"message":"Model not available on current plan"}"#, [:], .forbidden), + (#"{"status":403,"message":"Model not available on current plan","code":null}"#, [:], .modelUnavailable), + (#"{"status":413,"message":"Model not available on current plan"}"#, [:], .forbidden), + (#"{"status":403,"message":"Model not available on current plan soon"}"#, [:], .forbidden), + (legacy, ["x-opensecret-error-contract": "2"], .forbidden), + (#"{"status":403,"message":"Model not available on current plan","code":"unknown"}"#, [:], .forbidden), + (#"{"status":403,"message":"Model not available on current plan","error":{"code":false}}"#, [:], .forbidden), + ( + #"{"status":403,"message":"Model not available on current plan","code":"usage_limit_reached"}"#, [:], + .usageLimit + ), + ( + legacy, ["x-opensecret-error-contract": "1", "x-opensecret-error-code": "usage_limit_reached"], + .usageLimit + ), + ( + #"{"status":403,"message":"Model not available on current plan","code":"model_not_available_on_plan"}"#, + ["x-opensecret-error-contract": "1", "x-opensecret-error-code": "usage_limit_reached"], .forbidden + ), + ( + String( + data: try JSONEncoder().encode([ + "status": JSONValue.integer(403), "message": .string(message), + "padding": .string(String(repeating: "x", count: 4_096)), + ]), encoding: .utf8)!, [:], .forbidden + ), + ] + for (body, headers, reason) in cases { + let api = ResearchAPI(transport: APIRecorder([.init(status: 403, body: body, headers: headers)])) + await #expect(throws: ResearchAPIError.http(status: 403, reason: reason)) { + try await api.getConversation(id: "c") + } + } +} + +@Test(arguments: ["0", "60", "61", "999999999999999999999", "01", "not-a-delay"]) +func capacityContractAndBoundedRetryAfter(_ delay: String) throws { + let response = MapleResponse( + status: 503, + headers: [ + "X-OpenSecret-Error-Contract": "1", "x-opensecret-error-code": "inference_capacity", + "x-opensecret-client-replay": "safe", "retry-after": delay, + ], body: AsyncThrowingStream { $0.finish() }, inferenceSendCount: 2) + let error = try #require(OpenSecretInferenceCapacityError(response: response)) + #expect(error.inferenceSendCount == 2) + let expected: Int? = ["0": 0, "60": 60_000, "01": 1_000, "not-a-delay": 1_000][delay] + #expect(error.retryDelayMilliseconds == expected) + #expect( + OpenSecretInferenceCapacityError( + response: MapleResponse(status: 503, body: AsyncThrowingStream { $0.finish() })) == nil) +} + +@Test func capacityFinderRecognizesBoundedFoundationWrappers() throws { + let response = MapleResponse( + status: 429, + headers: [ + "x-opensecret-error-contract": "1", "x-opensecret-error-code": "inference_capacity", + "x-opensecret-client-replay": "safe", + ], body: AsyncThrowingStream { $0.finish() }) + let capacity = try #require(OpenSecretInferenceCapacityError(response: response)) + let wrapper = NSError(domain: "SyntheticWrapper", code: 0, userInfo: [NSUnderlyingErrorKey: capacity]) + #expect(findOpenSecretInferenceCapacityError(wrapper) == capacity) + #expect(findOpenSecretInferenceCapacityError(ResearchAPIError.invalidResponse) == nil) + var nested: any Error = capacity + for _ in 0..<8 { nested = NSError(domain: "SyntheticWrapper", code: 0, userInfo: [NSUnderlyingErrorKey: nested]) } + #expect(findOpenSecretInferenceCapacityError(nested) == nil) +} + +@Test func accountAndPlanFailuresRetainOnlyPublicCategories() async throws { + let cases: [(Int, String, ResearchAPIError.RejectionReason)] = [ + (401, "Invalid email, password, or login method", .invalidLogin), + (401, "Invalid invite code", .invalidInviteCode), + (409, "Email already registered", .emailAlreadyRegistered), + (409, "User exists but Oauth not linked", .oauthNotLinked), + (404, "User not found", .userNotFound), + (400, "No valid email found for the Oauth account", .oauthEmailUnavailable), + (400, "User is already verified", .alreadyVerified), + (403, "Model not available on current plan", .modelUnavailable), + (413, "Payload too large", .payloadTooLarge), + (429, "Too many requests", .rateLimited), + ] + for (status, message, expected) in cases { + let body = String( + data: try JSONEncoder().encode(["status": JSONValue.integer(Int64(status)), "message": .string(message)]), + encoding: .utf8)! + let api = ResearchAPI(transport: APIRecorder([.init(status: status, body: body)])) + await #expect(throws: ResearchAPIError.http(status: status, reason: expected)) { try await api.getUser() } + } + let api = ResearchAPI( + transport: APIRecorder([ + .init( + status: 503, body: "private-canary", + headers: [ + "x-opensecret-error-contract": "1", "x-opensecret-error-code": "billing_account_deletion_failed", + ]) + ])) + await #expect(throws: ResearchAPIError.http(status: 503, reason: .billingCleanupUnavailable)) { + try await api.confirmAccountDeletion(code: "code", plaintextSecret: "secret") + } +} diff --git a/sdk/swift/Tests/MapleSDKTests/API/ResponsesAPITests.swift b/sdk/swift/Tests/MapleSDKTests/API/ResponsesAPITests.swift new file mode 100644 index 000000000..423d03bf8 --- /dev/null +++ b/sdk/swift/Tests/MapleSDKTests/API/ResponsesAPITests.swift @@ -0,0 +1,308 @@ +import Foundation +import Testing +@testable import MapleSDK + +private func lifecycle(_ type: String, status: String) -> String { + "{\"type\":\"\(type)\",\"sequence_number\":1,\"response\":{\"id\":\"response-1\",\"object\":\"response\",\"created_at\":1,\"status\":\"\(status)\",\"model\":\"auto:quick\",\"output\":[]}}" +} +private func sse(_ type: String, _ json: String, newline: String = "\n") -> String { + "event: \(type)\(newline)data: \(json)\(newline)\(newline)" +} +private let delta = + #"{"type":"response.output_text.delta","item_id":"item-1","output_index":0,"content_index":0,"sequence_number":2,"delta":"Hello 🌍"}"# + +@Test func sseHandlesEveryByteSplitAndWithholdsTerminalUntilEOF() throws { + let fixture = + sse("response.created", lifecycle("response.created", status: "in_progress")) + + sse("response.output_text.delta", delta) + + sse("response.completed", lifecycle("response.completed", status: "completed")) + + "data: [DONE]\n\n" + let bytes = Data(fixture.utf8) + for split in 0...bytes.count { + var decoder = ResponsesSSEDecoder() + let first = try decoder.append(bytes.prefix(split)) + let second = try decoder.append(bytes.dropFirst(split)) + #expect((first + second).count == 2) + #expect(!(first + second).contains(where: { $0.isTerminal })) + let terminal = try decoder.finish() + #expect(terminal.count == 1 && terminal[0].isTerminal) + } +} + +@Test(arguments: ["\n", "\r\n", "\r"]) +func sseNewlineCommentsAndBytewiseUTF8(_ newline: String) throws { + let fixture = + ": heartbeat\(newline)\(newline)" + sse("response.output_text.delta", delta, newline: newline) + + sse("response.completed", lifecycle("response.completed", status: "completed"), newline: newline) + var decoder = ResponsesSSEDecoder() + var events: [ResponseStreamEvent] = [] + for byte in fixture.utf8 { events += try decoder.append(Data([byte])) } + events += try decoder.finish() + #expect(events.count == 2) + guard case let .outputTextDelta(text) = events[0] else { Issue.record("Expected delta"); return } + #expect(text.delta == "Hello 🌍") +} + +@Test func sseRejectsTruncationMalformedPayloadAndPostTerminalData() throws { + let complete = sse("response.completed", lifecycle("response.completed", status: "completed")) + for text in ["", sse("response.created", lifecycle("response.created", status: "in_progress")), "data: [DONE]\n\n"] + { + #expect(throws: ResearchAPIError.self) { + var decoder = ResponsesSSEDecoder() + _ = try decoder.append(Data(text.utf8)); _ = try decoder.finish() + } + } + for text in [ + complete + sse("response.output_text.delta", delta), complete + complete, + sse("response.completed", lifecycle("response.completed", status: "in_progress")), + sse("response.completed", "not json"), sse("response.completed", delta), + "data: {\"type\":\"response.output_text.delta\"}\n\n", + ] { + #expect(throws: ResearchAPIError.invalidStream) { + var decoder = ResponsesSSEDecoder(); _ = try decoder.append(Data(text.utf8)); _ = try decoder.finish() + } + } + #expect(throws: ResearchAPIError.responseTooLarge) { + var decoder = ResponsesSSEDecoder(maximumEventBytes: 8) + _ = try decoder.append(Data("data: 123456789".utf8)) + } + #expect(throws: ResearchAPIError.invalidStream) { + var decoder = ResponsesSSEDecoder() + _ = try decoder.append(Data([100, 97, 116, 97, 58, 32, 0xFF, 10])) + } +} + +@Test func sseDecodesResearchEventSurfaceAndRetainsUnknownEvents() throws { + let textBody = #""item_id":"i","output_index":0,"content_index":0,"delta":"D","text":"T""# + let itemBody = + #""output_index":0,"item":{"id":"i","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"T"}]}"# + let partBody = #""item_id":"i","output_index":0,"content_index":0,"part":{"type":"output_text","text":"T"}"# + let fixtures: [(String, String)] = [ + ("response.output_item.added", itemBody), ("response.output_item.done", itemBody), + ("response.content_part.added", partBody), ("response.content_part.done", partBody), + ("response.output_text.delta", textBody), ("response.output_text.done", textBody), + ("response.reasoning_text.delta", textBody), ("response.reasoning_text.done", textBody), + ("response.web_search_call.in_progress", #""item_id":"i","output_index":0"#), + ("response.web_search_call.searching", #""item_id":"i","output_index":0"#), + ("response.web_search_call.completed", #""item_id":"i","output_index":0"#), + ("tool_call.created", #""tool_call_id":"t","output_index":0,"name":"web_search","arguments":{"query":"q"}"#), + ("tool_output.created", #""tool_output_id":"o","tool_call_id":"t","output_index":0,"output":"result""#), + ("future.event", #""new_field":true"#), + ] + var decoder = ResponsesSSEDecoder() + var events: [ResponseStreamEvent] = [] + for (type, fields) in fixtures { + events += try decoder.append(Data(sse(type, "{\"type\":\"\(type)\",\(fields)}").utf8)) + } + #expect(events.count == fixtures.count) + guard case let .unknown(type, payload) = events.last else { Issue.record("Expected unknown extension"); return } + #expect(type == "future.event") + #expect(payload == .object(["type": .string("future.event"), "new_field": .bool(true)])) +} + +@Test(arguments: ["response.failed", "response.cancelled", "error", "response.error"]) +func sseDistinguishesTerminalFailureAndCancellation(_ type: String) throws { + let payload: String + if type == "response.failed" { + payload = lifecycle(type, status: "failed") + } else if type == "response.cancelled" { + payload = #"{"type":"response.cancelled","id":"event","created_at":1,"data":{"id":"response-1"}}"# + } else { + payload = "{\"type\":\"\(type)\",\"code\":\"failure\",\"message\":\"safe\"}" + } + var decoder = ResponsesSSEDecoder() + #expect(try decoder.append(Data(sse(type, payload).utf8)).isEmpty) + #expect(try decoder.finish().first?.isTerminal == true) +} + +@Test func responsesCreateRetrieveCancelWireAndSSEIntegration() async throws { + let completion = sse("response.completed", lifecycle("response.completed", status: "completed")) + let retrieved = + #"{"id":"response-1","object":"response","created_at":1,"status":"completed","model":"auto:quick","output":[],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}"# + let cancelled = #"{"id":"response-1","object":"response","created_at":1,"status":"cancelled","model":"auto:quick"}"# + let transport = APIRecorder([ + .init(body: completion, headers: ["content-type": "text/event-stream; charset=utf-8"]), .init(body: retrieved), + .init(body: cancelled), + ]) + let api = ResearchAPI(transport: transport, expectedPrincipalId: "owner") + let input = ResponseInput.messages([.init(content: [.text("Hello"), .image(url: "data:image/png;base64,AA==")])]) + let events = try await api.createResponse( + .init( + input: input, conversation: "conversation-1", metadata: ["internal_message_id": .string("correlation")], + webSearch: true), maximumInferenceSends: 1) + var received: [ResponseStreamEvent] = [] + for try await event in events { received.append(event) } + #expect(received.count == 1 && received[0].isTerminal) + #expect(try await api.retrieveResponse(id: "response-1").usage?.totalTokens == 3) + #expect(try await api.cancelResponse(id: "response-1").status == .cancelled) + let requests = await transport.snapshot() + #expect(requests.map(\.target) == ["/v1/responses", "/v1/responses/response-1", "/v1/responses/response-1/cancel"]) + #expect(requests[0].maximumInferenceSends == 1) + #expect(requests.allSatisfy { $0.expectedPrincipalId == "owner" }) + let body = try JSONDecoder().decode([String: JSONValue].self, from: #require(requests[0].body)) + #expect(body["stream"] == .bool(true) && body["store"] == .bool(true)) + #expect(body["metadata"] == .object(["internal_message_id": .string("correlation")])) + #expect(body["tools"] == .array([.object(["type": .string("web_search")])])) + #expect(body["reasoning"] == nil) + #expect( + body["input"] + == .array([ + .object([ + "role": .string("user"), + "content": .array([ + .object(["type": .string("input_text"), "text": .string("Hello")]), + .object([ + "type": .string("input_image"), "image_url": .string("data:image/png;base64,AA=="), + "detail": .string("auto"), + ]), + ]), + ]) + ])) +} + +@Test(arguments: ReasoningEffort.allCases) +func responsesThinkingEffortUsesNestedResponsesWireContract(effort: ReasoningEffort) async throws { + let transport = APIRecorder([ + .init( + body: sse("response.completed", lifecycle("response.completed", status: "completed")), + headers: ["content-type": "text/event-stream"]) + ]) + let api = ResearchAPI(transport: transport, expectedPrincipalId: "owner") + let stream = try await api.createResponse( + .init(input: .text("Synthetic"), conversation: "c", reasoningEffort: effort), maximumInferenceSends: 1) + for try await _ in stream {} + let request = try #require(await transport.snapshot().first) + let body = try JSONDecoder().decode([String: JSONValue].self, from: #require(request.body)) + #expect(body["reasoning"] == .object(["effort": .string(effort.rawValue)])) + #expect(body["reasoning_effort"] == nil) + #expect(request.expectedPrincipalId == "owner" && request.maximumInferenceSends == 1) +} + +private struct FixtureStreamTransport: MapleTransport { + let response: MapleResponse + func send(_ request: MapleRequest) async throws -> MapleResponse { response } +} + +private actor StalledResponseFixture { + private let chunks: [Data] + private var index = 0 + private(set) var reads = 0 + init(_ chunks: [Data]) { self.chunks = chunks } + func next() -> Data? { + reads += 1 + guard index < chunks.count else { return nil } + defer { index += 1 } + return chunks[index] + } +} + +@Test func stalledResponseConsumerPreservesEveryEventAndTerminal() async throws { + let chunks = + Array(repeating: Data(sse("response.output_text.delta", delta).utf8), count: 600) + + [Data(sse("response.completed", lifecycle("response.completed", status: "completed")).utf8)] + let source = StalledResponseFixture(chunks) + let api = ResearchAPI( + transport: FixtureStreamTransport( + response: MapleResponse( + status: 200, headers: ["content-type": "text/event-stream"], + body: AsyncThrowingStream(unfolding: { await source.next() })))) + let events = try await api.createResponse(.init(input: .text("Synthetic"), conversation: "c")) + // Allow more than the old 256-event capacity to accumulate before reading. + try await Task.sleep(for: .milliseconds(30)) + var iterator = events.makeAsyncIterator() + #expect(try await iterator.next()?.isTerminal == false) + try await Task.sleep(for: .milliseconds(30)) + var deltas = 1, terminal = 0 + while let event = try await iterator.next() { + if event.isTerminal { terminal += 1 } else { deltas += 1 } + } + #expect(deltas == 600 && terminal == 1) + #expect(await source.reads == chunks.count + 1) +} + +@Test func authenticatedTunnelFailureAfterTerminalIsNotSuccess() async throws { + let fixture = sse("response.completed", lifecycle("response.completed", status: "completed")) + let body = AsyncThrowingStream { continuation in + continuation.yield(Data(fixture.utf8)) + continuation.finish(throwing: ResearchAPIError.invalidStream) + } + let api = ResearchAPI( + transport: FixtureStreamTransport( + response: MapleResponse( + status: 200, + headers: ["content-type": "text/event-stream"], body: body))) + let events = try await api.createResponse(.init(input: .text("Synthetic"), conversation: "c")) + var received: [ResponseStreamEvent] = [] + do { + for try await event in events { received.append(event) } + Issue.record("Authentication failure must propagate") + } catch { #expect(error as? ResearchAPIError == .invalidStream) } + #expect(received.isEmpty) +} + +private actor StreamCancellationProbe { + private var wasCancelled = false + private var waiter: CheckedContinuation? + private var consumed = false + private var consumedWaiter: CheckedContinuation? + func cancel() { + wasCancelled = true + waiter?.resume() + waiter = nil + } + func wait() async { + if wasCancelled { return } + await withCheckedContinuation { waiter = $0 } + } + func didConsume() { + consumed = true + consumedWaiter?.resume(); consumedWaiter = nil + } + func waitUntilConsumed() async { + if consumed { return } + await withCheckedContinuation { consumedWaiter = $0 } + } +} + +@Test func responseConsumerCancellationCancelsUpstream() async throws { + let probe = StreamCancellationProbe() + let body = AsyncThrowingStream { continuation in + continuation.onTermination = { @Sendable _ in Task { await probe.cancel() } } + continuation.yield(Data(sse("response.output_text.delta", delta).utf8)) + } + let api = ResearchAPI( + transport: FixtureStreamTransport( + response: MapleResponse( + status: 200, + headers: ["content-type": "text/event-stream"], body: body))) + let consumer = Task { + let events = try await api.createResponse(.init(input: .text("Synthetic"), conversation: "c")) + for try await _ in events { try Task.checkCancellation() } + } + consumer.cancel() + _ = await consumer.result + await probe.wait() +} + +@Test(arguments: [1, 600]) +func responseCancellationAfterFirstChunkCancelsBufferedUpstream(eventCount: Int) async throws { + let probe = StreamCancellationProbe() + let body = AsyncThrowingStream { continuation in + continuation.onTermination = { @Sendable _ in Task { await probe.cancel() } } + continuation.yield(Data(String(repeating: sse("response.output_text.delta", delta), count: eventCount).utf8)) + } + let api = ResearchAPI( + transport: FixtureStreamTransport( + response: MapleResponse(status: 200, headers: ["content-type": "text/event-stream"], body: body))) + let consumer = Task { + let events = try await api.createResponse(.init(input: .text("Synthetic"), conversation: "c")) + for try await _ in events { + await probe.didConsume() + try await Task.sleep(for: .seconds(60)) + } + } + await probe.waitUntilConsumed() + consumer.cancel() + _ = await consumer.result + await probe.wait() +} diff --git a/sdk/swift/Tests/MapleSDKTests/Transport/AccountCeremonyBoundaryTests.swift b/sdk/swift/Tests/MapleSDKTests/Transport/AccountCeremonyBoundaryTests.swift new file mode 100644 index 000000000..5d6547e11 --- /dev/null +++ b/sdk/swift/Tests/MapleSDKTests/Transport/AccountCeremonyBoundaryTests.swift @@ -0,0 +1,42 @@ +import Foundation +import Testing +@testable import MapleSDK + +@Suite struct AccountCeremonyBoundaryTests { + @Test(arguments: [false, true]) + func staleCeremonyCannotChangeOrDeleteReplacementAccount(samePrincipal: Bool) async throws { + let client = try MapleClient( + apiURL: "https://example.invalid", clientID: UUID().uuidString, + policy: PCRTrustPolicy(environment: .development, remoteHistoryEnabled: false), + store: TestSecureStore()) + let first = try await client.credentials.install( + accessToken: fixtureJWT(principal: "a", kind: "access"), + refreshToken: fixtureJWT(principal: "a", kind: "refresh"), expected: client.credentials.snapshot()) + try await client.credentials.clear(expected: first) + let replacement = samePrincipal ? "a" : "b" + let current = try await client.credentials.install( + accessToken: fixtureJWT(principal: replacement, kind: "access"), + refreshToken: fixtureJWT(principal: replacement, kind: "refresh"), expected: client.credentials.snapshot()) + // The prior screen resumes after sign-out/re-login. Neither convenience + // API may adopt the replacement merely because it is current now. + await #expect(throws: TransportError.authorityChanged) { + try await client.changePassword(currentPassword: "old", newPassword: "new", expected: first) + } + await #expect(throws: TransportError.authorityChanged) { + try await client.confirmAccountDeletion(code: "CODE1234", plaintextSecret: "secret", expected: first) + } + #expect(try await client.credentials.snapshot() == current) + } + + @Test func anonymousDeletionNeverDispatches() async throws { + let client = try MapleClient( + apiURL: "https://example.invalid", clientID: UUID().uuidString, + policy: PCRTrustPolicy(environment: .development, remoteHistoryEnabled: false), + store: TestSecureStore()) + let anonymous = try await client.credentials.snapshot() + await #expect(throws: TransportError.signedOut) { + try await client.confirmAccountDeletion(code: "CODE1234", plaintextSecret: "secret", expected: anonymous) + } + #expect(try await client.credentials.snapshot() == anonymous) + } +} diff --git a/sdk/swift/Tests/MapleSDKTests/Transport/AuthorityLifecycleTests.swift b/sdk/swift/Tests/MapleSDKTests/Transport/AuthorityLifecycleTests.swift new file mode 100644 index 000000000..7d6280c63 --- /dev/null +++ b/sdk/swift/Tests/MapleSDKTests/Transport/AuthorityLifecycleTests.swift @@ -0,0 +1,89 @@ +import Foundation +import Testing +@testable import MapleSDK + +@Suite struct AuthorityLifecycleTests { + @Test func transportRejectsOldAccountLeaseBeforeDispatchEvenForSamePrincipal() async throws { + let helper = EncryptedTransportTests(), network = FixtureNetwork() + let transport = try await helper.make(network, signedIn: true) + let oldScreen = try await transport.credentials.snapshot() + try await transport.credentials.clear() + _ = try await transport.credentials.install( + accessToken: fixtureJWT(principal: "a", kind: "access"), + refreshToken: fixtureJWT(principal: "a", kind: "refresh"), expected: transport.credentials.snapshot()) + await #expect(throws: TransportError.authorityChanged) { + try await transport.send( + MapleRequest( + method: .delete, target: "/v1/conversations/example", + expectedPrincipalId: "a"), expectedAccount: oldScreen) + } + #expect(await network.handshakes == 0) + #expect(await network.observations.isEmpty) + } + + @Test func sameKeychainNamespaceSharesAuthorityAndFencesLateSignIn() async throws { + let registry = ClientRegistry(), store = TestSecureStore() + let policy = try PCRTrustPolicy(environment: .development) + let (first, firstTransport) = try registry.components( + apiURL: "https://EXAMPLE.test:443/", policy: policy, + store: store, namespace: "test-keychain") + let pendingSignIn = try await first.snapshot() + let (second, secondTransport) = try registry.components( + apiURL: "https://example.test", policy: policy, + store: store, namespace: "test-keychain") + #expect(first === second && firstTransport === secondTransport) + let (otherPath, otherTransport) = try registry.components( + apiURL: "https://example.test/other", policy: policy, + store: store, namespace: "test-keychain") + #expect(otherPath === first && otherTransport !== firstTransport) + try await second.clear() + await #expect(throws: TransportError.authorityChanged) { + try await first.install( + accessToken: fixtureJWT(principal: "a", kind: "access"), + refreshToken: fixtureJWT(principal: "a", kind: "refresh"), expected: pendingSignIn) + } + } + + @Test func canceledSignInCannotCommitTokens() async throws { + let authority = try CredentialAuthority(apiURL: "https://example.test", store: TestSecureStore()) + let snapshot = try await authority.snapshot() + let attempt = Task { + withUnsafeCurrentTask { $0?.cancel() } + return try await authority.install( + accessToken: fixtureJWT(principal: "a", kind: "access"), + refreshToken: fixtureJWT(principal: "a", kind: "refresh"), expected: snapshot) + } + await #expect(throws: CancellationError.self) { try await attempt.value } + #expect(try await authority.snapshot() == snapshot) + } + + @Test func accountDeletionClearsAcrossRefreshButCannotClearReplacement() async throws { + let authority = try CredentialAuthority(apiURL: "https://example.test", store: TestSecureStore()) + let a = try fixtureJWT(principal: "a", kind: "access"), r = try fixtureJWT(principal: "a", kind: "refresh") + let original = try await authority.install(accessToken: a, refreshToken: r, expected: authority.snapshot()) + _ = try await authority.install(accessToken: a, refreshToken: r, expected: original, refreshing: true) + try await authority.clearAccount(expected: original) + _ = try await authority.install(accessToken: a, refreshToken: r, expected: authority.snapshot()) + await #expect(throws: TransportError.authorityChanged) { try await authority.clearAccount(expected: original) } + #expect(try await authority.authenticatedUserId() == "a") + } + + @Test func independentTransportsCoalesceRefreshForSharedAuthority() async throws { + let network = FixtureNetwork(), store = TestSecureStore() + let authority = try CredentialAuthority(apiURL: "http://127.0.0.1:3000", store: store) + _ = try await authority.install( + accessToken: fixtureJWT(principal: "a", kind: "access", expiry: 1), + refreshToken: fixtureJWT(principal: "a", kind: "refresh"), expected: authority.snapshot()) + func transport() throws -> EncryptedTransport { + try EncryptedTransport( + apiURL: "http://127.0.0.1:3000", policy: PCRTrustPolicy(environment: .development), + credentials: authority, store: store, network: network, now: Date.init) + } + let first = try transport(), second = try transport() + let request = MapleRequest(method: .get, target: "/protected/user") + async let a = first.send(request) + async let b = second.send(request) + _ = try await (a, b) + #expect(await network.observations.filter { $0.target == "/refresh" }.count == 1) + } +} diff --git a/sdk/swift/Tests/MapleSDKTests/Transport/CredentialAuthorityTests.swift b/sdk/swift/Tests/MapleSDKTests/Transport/CredentialAuthorityTests.swift new file mode 100644 index 000000000..b29bda7ad --- /dev/null +++ b/sdk/swift/Tests/MapleSDKTests/Transport/CredentialAuthorityTests.swift @@ -0,0 +1,227 @@ +import Foundation +import Testing +@testable import MapleSDK + +@Suite struct CredentialAuthorityTests { + @Test func tokenHintsRejectWrongAudienceFormatAndPrincipal() throws { + let a = try fixtureJWT(principal: "a", kind: "access") + let r = try fixtureJWT(principal: "a", kind: "refresh") + #expect(try CredentialPair(access: a, refresh: r).principal == "a") + #expect(throws: TransportError.invalidCredentials) { + try CredentialPair(access: a, refresh: fixtureJWT(principal: "b", kind: "refresh")) + } + #expect(throws: TransportError.invalidCredentials) { try CredentialPair(access: r, refresh: a) } + #expect(throws: TransportError.invalidCredentials) { + try CredentialPair(access: fixtureJWT(principal: "a", kind: "access", format: 1), refresh: r) + } + #expect(throws: TransportError.invalidCredentials) { try CredentialPair(access: a + "=", refresh: r) } + } + + @Test func CASAndSameAccountReloginFenceOldWork() async throws { + let authority = try CredentialAuthority(apiURL: "https://example.test", store: TestSecureStore()) + let anonymous = try await authority.snapshot() + let access = try fixtureJWT(principal: "a", kind: "access"), + refresh = try fixtureJWT(principal: "a", kind: "refresh") + let first = try await authority.install(accessToken: access, refreshToken: refresh, expected: anonymous) + await #expect(throws: TransportError.authorityChanged) { + try await authority.install(accessToken: access, refreshToken: refresh, expected: anonymous) + } + try await authority.clear(expected: first) + let second = try await authority.install( + accessToken: access, refreshToken: refresh, expected: authority.snapshot()) + #expect(first.generation != second.generation) + await #expect(throws: TransportError.authorityChanged) { + try await authority.assertCurrent(first, exactRevision: false) + } + #expect(second.revision > first.revision) + } + + @Test func refreshPreservesAccountLeaseButRequiresExactRevision() async throws { + let authority = try CredentialAuthority(apiURL: "https://example.test", store: TestSecureStore()) + let a = try fixtureJWT(principal: "a", kind: "access"), r = try fixtureJWT(principal: "a", kind: "refresh") + let original = try await authority.install(accessToken: a, refreshToken: r, expected: authority.snapshot()) + let renewed = try await authority.install(accessToken: a, refreshToken: r, expected: original, refreshing: true) + #expect(original.generation == renewed.generation) + try await authority.assertCurrent(original, exactRevision: false) + await #expect(throws: TransportError.authorityChanged) { try await authority.assertCurrent(original) } + } + + @Test func durableFailuresNeverPublishAndCleanupFailsClosed() async throws { + let storage = TestSecureStore() + let authority = try CredentialAuthority(apiURL: "https://example.test", store: storage) + let anonymous = try await authority.snapshot() + let a = try fixtureJWT(principal: "a", kind: "access"), r = try fixtureJWT(principal: "a", kind: "refresh") + storage.setWriteFailure(true) + await #expect(throws: TransportError.persistence) { + try await authority.install(accessToken: a, refreshToken: r, expected: anonymous) + } + #expect(try await authority.snapshot() == anonymous) + storage.setWriteFailure(false) + _ = try await authority.install(accessToken: a, refreshToken: r, expected: anonymous) + storage.setWriteFailure(true) + await #expect(throws: TransportError.persistence) { try await authority.clear() } + await #expect(throws: TransportError.persistence) { try await authority.snapshot() } + storage.setWriteFailure(false) + try await authority.clear() + #expect(try await authority.authenticatedUserId() == nil) + } + + @Test func restoreIsOriginScopedAndPreservesCacheRoot() async throws { + let storage = TestSecureStore() + let first = try CredentialAuthority(apiURL: "https://EXAMPLE.test:443/service/", store: storage) + let root = try await first.cacheRoot() + _ = try await first.install( + accessToken: fixtureJWT(principal: "a", kind: "access"), + refreshToken: fixtureJWT(principal: "a", kind: "refresh"), expected: first.snapshot()) + let restored = try CredentialAuthority(apiURL: "https://example.test/another", store: storage) + #expect(try await restored.authenticatedUserId() == "a") + #expect(try await restored.cacheRoot() == root) + let other = try CredentialAuthority(apiURL: "https://other.test", store: storage) + #expect(try await other.authenticatedUserId() == nil) + #expect(try await other.cacheRoot() != root) + } + + @Test func corruptPersistedAuthorityResetsOnlyItsOriginAndPublishesSignedOutState() async throws { + let origin = "https://example.test", key = "authority:" + origin + let access = try fixtureJWT(principal: "a", kind: "access") + let refresh = try fixtureJWT(principal: "a", kind: "refresh") + let valid: [String: Any] = [ + "version": 1, "origin": origin, "revision": 3, "generation": UUID().uuidString, + "access": access, "refresh": refresh, + ] + let invalid: [[String: Any]] = [ + valid.merging(["version": 2]) { _, new in new }, + valid.merging(["origin": "https://other.test"]) { _, new in new }, + valid.merging(["refresh": NSNull()]) { _, new in new }, + valid.merging(["cacheRoot": Data(repeating: 1, count: 31).base64EncodedString()]) { _, new in new }, + valid.merging(["access": "malformed-jwt"]) { _, new in new }, + valid.merging(["refresh": try fixtureJWT(principal: "b", kind: "refresh")]) { _, new in new }, + ] + let blobs = + [Data("malformed JSON".utf8), Data(repeating: 32, count: 96 * 1_024 + 1)] + + (try invalid.map { try JSONSerialization.data(withJSONObject: $0) }) + for blob in blobs { + let store = TestSecureStore() + try store.write(blob, key: key) + try store.write(Data("untouched".utf8), key: "authority:https://other.test") + let client = try MapleClient( + apiURL: origin, clientID: "00000000-0000-0000-0000-000000000001", + policy: PCRTrustPolicy(environment: .production), store: store) + let snapshot = try await client.credentials.snapshot() + #expect(snapshot.principalId == nil && snapshot.revision == 0) + var changes = await client.credentials.changes().makeAsyncIterator() + let change = await changes.next() + #expect(change?.generation == snapshot.generation && change?.requiresCleanup == false) + #expect(try store.read(key: key) == nil) + #expect(try store.read(key: "authority:https://other.test") == Data("untouched".utf8)) + _ = try await client.credentials.install(accessToken: access, refreshToken: refresh, expected: snapshot) + #expect(try await client.credentials.authenticatedUserId() == "a") + } + } + + @Test func corruptAuthorityCleanupFailureConstructsButBlocksUseUntilDurableCleanup() async throws { + let store = TestSecureStore() + try store.write(Data("malformed JSON".utf8), key: "authority:https://example.test") + store.setWriteFailure(true) + let authority = try CredentialAuthority(apiURL: "https://example.test", store: store) + var changes = await authority.changes().makeAsyncIterator() + let initial = await changes.next() + #expect(initial?.requiresCleanup == true) + await #expect(throws: TransportError.persistence) { try await authority.snapshot() } + await #expect(throws: TransportError.persistence) { try await authority.completePendingCleanup() } + store.setWriteFailure(false) + try await authority.completePendingCleanup() + let repaired = await changes.next() + #expect(repaired?.generation == initial?.generation && repaired?.requiresCleanup == false) + #expect(try await authority.authenticatedUserId() == nil) + let restored = try CredentialAuthority(apiURL: "https://example.test", store: store) + #expect(try await restored.authenticatedUserId() == nil) + } + + @Test func unreadableCredentialAuthorityStillFailsClosed() throws { + let store = TestSecureStore() + store.setReadFailure(true) + #expect(throws: TransportError.persistence) { + try CredentialAuthority(apiURL: "https://example.test", store: store) + } + } + + @Test func cancelledRefreshCallerKeepsSharedRotationAliveForOtherCallers() async throws { + let authority = try CredentialAuthority(apiURL: "https://example.test", store: TestSecureStore()) + let access = try fixtureJWT(principal: "a", kind: "access") + let refresh = try fixtureJWT(principal: "a", kind: "refresh") + let expected = try await authority.install( + accessToken: access, refreshToken: refresh, expected: authority.snapshot()) + let gate = RefreshSuspension() + let operation: @Sendable () async throws -> Void = { + await gate.run() + _ = try await authority.install( + accessToken: access, refreshToken: refresh, expected: expected, refreshing: true) + } + let caller = Task { try await authority.coalescedRefresh(expected: expected, operation: operation) } + await gate.waitUntilStarted() + caller.cancel() + let follower = Task { try await authority.coalescedRefresh(expected: expected, operation: operation) } + // The shared operation remains suspended while the other waiter joins. + try await Task.sleep(for: .milliseconds(30)) + #expect(await gate.runs == 1) + await gate.release() + await #expect(throws: CancellationError.self) { try await caller.value } + try await follower.value + let renewed = try await authority.snapshot() + #expect(renewed.generation == expected.generation && renewed.revision == expected.revision + 1) + #expect(await gate.runs == 1) + } + + @Test func sharedRefreshCannotInstallAfterSameAccountReplacement() async throws { + let authority = try CredentialAuthority(apiURL: "https://example.test", store: TestSecureStore()) + let access = try fixtureJWT(principal: "a", kind: "access") + let refresh = try fixtureJWT(principal: "a", kind: "refresh") + let expected = try await authority.install( + accessToken: access, refreshToken: refresh, expected: authority.snapshot()) + let gate = RefreshSuspension() + let pending = Task { + try await authority.coalescedRefresh(expected: expected) { + await gate.run() + _ = try await authority.install( + accessToken: access, refreshToken: refresh, expected: expected, refreshing: true) + } + } + await gate.waitUntilStarted() + let replacement = try await authority.install(accessToken: access, refreshToken: refresh, expected: expected) + await gate.release() + await #expect(throws: TransportError.authorityChanged) { try await pending.value } + #expect(try await authority.snapshot() == replacement) + } + + @Test func endpointRejectsUnsafeOrigins() throws { + for value in [ + "http://example.test", "https://user@example.test", "https://example.test?token=x", + "https://example.test/#x", "http://127.1", "http://127.0.0.2", + ] { + #expect(throws: TransportError.invalidEndpoint) { try APIEndpoint(value) } + } + #expect(try APIEndpoint("http://127.0.0.1:3000").isLoopback) + #expect(try APIEndpoint("http://[::1]:3000").isLoopback) + } +} + +private actor RefreshSuspension { + private(set) var runs = 0 + private var started: CheckedContinuation? + private var waiting: [CheckedContinuation] = [] + + func run() async { + runs += 1 + started?.resume(); started = nil + await withCheckedContinuation { waiting.append($0) } + } + func waitUntilStarted() async { + if runs > 0 { return } + await withCheckedContinuation { started = $0 } + } + func release() { + for continuation in waiting { continuation.resume() } + waiting.removeAll() + } +} diff --git a/sdk/swift/Tests/MapleSDKTests/Transport/CredentialObservationTests.swift b/sdk/swift/Tests/MapleSDKTests/Transport/CredentialObservationTests.swift new file mode 100644 index 000000000..018e804cb --- /dev/null +++ b/sdk/swift/Tests/MapleSDKTests/Transport/CredentialObservationTests.swift @@ -0,0 +1,178 @@ +import Foundation +import Testing +@testable import MapleSDK + +private actor AuthorityEvents { + var values: [CredentialAuthorityChange] = [] + func append(_ value: CredentialAuthorityChange) { values.append(value) } +} + +@Suite struct CredentialObservationTests { + @Test func lateSubscriberStartsWithReplacementAndSlowSubscriberGetsNewest() async throws { + let authority = try CredentialAuthority(apiURL: "https://example.test", store: TestSecureStore()) + let early = await authority.changes() + let first = try await authority.install( + accessToken: fixtureJWT(principal: "a", kind: "access"), + refreshToken: fixtureJWT(principal: "a", kind: "refresh"), expected: authority.snapshot()) + let late = await authority.changes() + var lateIterator = late.makeAsyncIterator() + let initial = await lateIterator.next() + #expect(initial?.generation == first.generation) + try await authority.clear() + let second = try await authority.install( + accessToken: fixtureJWT(principal: "a", kind: "access"), + refreshToken: fixtureJWT(principal: "a", kind: "refresh"), expected: authority.snapshot()) + var earlyIterator = early.makeAsyncIterator() + let newest = await earlyIterator.next() + #expect(newest?.generation == second.generation) + #expect(newest?.generation != first.generation) + #expect(newest?.requiresCleanup == false) + } + + @Test func refreshDoesNotInvalidateObserversAndCancellationUnregisters() async throws { + let authority = try CredentialAuthority(apiURL: "https://example.test", store: TestSecureStore()) + let access = try fixtureJWT(principal: "a", kind: "access") + let refresh = try fixtureJWT(principal: "a", kind: "refresh") + let first = try await authority.install( + accessToken: access, refreshToken: refresh, expected: authority.snapshot()) + let stream = await authority.changes(), events = AuthorityEvents() + let observation = Task { + for await value in stream { await events.append(value) } + } + for _ in 0..<1_000 { + if await events.values.count == 1 { break } + await Task.yield() + } + _ = try await authority.install(accessToken: access, refreshToken: refresh, expected: first, refreshing: true) + // Allow delivery of any incorrectly published refresh event while the + // observer is already waiting. This is not a server or polling delay. + try await Task.sleep(for: .milliseconds(30)) + #expect(await events.values.count == 1) + #expect(await authority.observerCount == 1) + observation.cancel(); await observation.value + for _ in 0..<1_000 { + if await authority.observerCount == 0 { break } + await Task.yield() + } + #expect(await authority.observerCount == 0) + } + + @Test func failedClearPublishesRevocationAndRetryNeverClearsReplacement() async throws { + let store = TestSecureStore() + let authority = try CredentialAuthority(apiURL: "https://example.test", store: store) + let first = try await authority.install( + accessToken: fixtureJWT(principal: "a", kind: "access"), + refreshToken: fixtureJWT(principal: "a", kind: "refresh"), expected: authority.snapshot()) + var changes = await authority.changes().makeAsyncIterator() + _ = await changes.next() + store.setWriteFailure(true) + await #expect(throws: TransportError.persistence) { try await authority.clear(expected: first) } + let revokedValue = await changes.next() + let revoked = try #require(revokedValue) + #expect(revoked.generation != first.generation && revoked.requiresCleanup) + #expect(await authority.currentState() == revoked) + await #expect(throws: TransportError.persistence) { try await authority.snapshot() } + await #expect(throws: TransportError.persistence) { try await authority.completePendingCleanup() } + store.setWriteFailure(false) + try await authority.completePendingCleanup() + let repairedValue = await changes.next() + let repaired = try #require(repairedValue) + #expect(repaired.generation == revoked.generation && !repaired.requiresCleanup) + #expect(try await authority.authenticatedUserId() == nil) + let replacement = try await authority.install( + accessToken: fixtureJWT(principal: "b", kind: "access"), + refreshToken: fixtureJWT(principal: "b", kind: "refresh"), expected: authority.snapshot()) + try await authority.completePendingCleanup() + #expect(try await authority.snapshot() == replacement) + } + + @Test(arguments: [401, 403]) + func confirmedRefreshRejectionPublishesRevocation(status: Int) async throws { + let network = FixtureNetwork() + await network.setStatus(status) + let transport = try await EncryptedTransportTests().make(network, signedIn: true, expired: true) + let prior = await transport.credentials.currentState() + var changes = await transport.credentials.changes().makeAsyncIterator() + _ = await changes.next() + await #expect(throws: TransportError.signedOut) { + try await transport.send(MapleRequest(method: .get, target: "/test")) + } + let nextValue = await changes.next() + let next = try #require(nextValue) + #expect(next.generation != prior.generation && !next.requiresCleanup) + #expect(try await transport.credentials.authenticatedUserId() == nil) + #expect(await network.observations.map(\.target) == ["/refresh"]) + } + + @Test func ordinaryUnauthorizedResponseAndTransientRefreshPreserveAuthority() async throws { + let network = FixtureNetwork() + await network.setStatus(401) + let transport = try await EncryptedTransportTests().make(network, signedIn: true) + let prior = await transport.credentials.currentState() + let response = try await transport.send(MapleRequest(method: .get, target: "/test")) + _ = try await EncryptedTransportTests().collect(response) + #expect(response.status == 401) + #expect(await transport.credentials.currentState() == prior) + let transient = FixtureNetwork() + await transient.setStatus(503) + let other = try await EncryptedTransportTests().make(transient, signedIn: true, expired: true) + let old = await other.credentials.currentState() + do { + _ = try await other.send(MapleRequest(method: .get, target: "/test")); + Issue.record("Expected refresh failure") + } catch { #expect(await other.credentials.currentState() == old) } + } + + @Test func scopedSignOutRejectsSamePrincipalReplacementAndRevokeAllowsRoutineRefresh() async throws { + let client = try MapleClient( + apiURL: "http://127.0.0.1:1234", clientID: "00000000-0000-0000-0000-000000000001", + policy: PCRTrustPolicy(environment: .development), store: TestSecureStore()) + let access = try fixtureJWT(principal: "a", kind: "access"), + refresh = try fixtureJWT(principal: "a", kind: "refresh") + let first = try await client.credentials.install( + accessToken: access, refreshToken: refresh, expected: client.credentials.snapshot()) + let replacement = try await client.credentials.install( + accessToken: access, refreshToken: refresh, expected: first) + await #expect(throws: TransportError.authorityChanged) { try await client.signOut(expected: first) } + #expect(try await client.credentials.snapshot() == replacement) + _ = try await client.credentials.install( + accessToken: access, refreshToken: refresh, expected: replacement, refreshing: true) + let revoked = try await client.credentials.revoke(expected: replacement) + #expect(revoked == refresh) + #expect(try await client.credentials.authenticatedUserId() == nil) + } + + @Test func clientSignOutCanFinishAPreviouslyFailedDurableClear() async throws { + let store = TestSecureStore() + let client = try MapleClient( + apiURL: "http://127.0.0.1:1234", clientID: "00000000-0000-0000-0000-000000000001", + policy: PCRTrustPolicy(environment: .development), store: store) + _ = try await client.credentials.install( + accessToken: fixtureJWT(principal: "a", kind: "access"), + refreshToken: fixtureJWT(principal: "a", kind: "refresh"), expected: client.credentials.snapshot()) + store.setWriteFailure(true) + await #expect(throws: TransportError.persistence) { try await client.credentials.clear() } + store.setWriteFailure(false) + // The revoked in-memory pair is absent, so this retry needs no request + // and cannot leak the previously persisted refresh token to a new caller. + try await client.signOut() + #expect(try await client.credentials.authenticatedUserId() == nil) + #expect(await client.credentials.currentState().requiresCleanup == false) + } + + @Test func failedInstallLeavesObserverAuthorityUnchanged() async throws { + let store = TestSecureStore() + let authority = try CredentialAuthority(apiURL: "https://example.test", store: store) + let original = await authority.currentState() + store.setWriteFailure(true) + await #expect(throws: TransportError.persistence) { + try await authority.install( + accessToken: fixtureJWT(principal: "a", kind: "access"), + refreshToken: fixtureJWT(principal: "a", kind: "refresh"), expected: authority.snapshot()) + } + #expect(await authority.currentState() == original) + var changes = await authority.changes().makeAsyncIterator() + let first = await changes.next() + #expect(first == original) + } +} diff --git a/sdk/swift/Tests/MapleSDKTests/Transport/EncryptedTransportTests.swift b/sdk/swift/Tests/MapleSDKTests/Transport/EncryptedTransportTests.swift new file mode 100644 index 000000000..93a73b2de --- /dev/null +++ b/sdk/swift/Tests/MapleSDKTests/Transport/EncryptedTransportTests.swift @@ -0,0 +1,205 @@ +import Foundation +import Testing +@testable import MapleSDK + +@Suite struct EncryptedTransportTests { + @Test func expiryRefreshRequiresAuthenticatedFinalityAndNeverReplays() async throws { + let truncated = FixtureNetwork([.truncated]), first = try await make(truncated, signedIn: true) + await truncated.expireNextAccess() + await #expect(throws: TransportError.truncated) { + try await first.send(MapleRequest(method: .post, target: "/v1/responses")) + } + #expect(await truncated.observations.map(\.target) == ["/v1/responses"]) + let valid = FixtureNetwork(), second = try await make(valid, signedIn: true) + await valid.expireNextAccess() + let response = try await second.send(MapleRequest(method: .post, target: "/v1/responses")) + #expect(response.status == 401) + _ = try await collect(response) + #expect(await valid.observations.map(\.target) == ["/v1/responses", "/refresh"]) + } + + func make( + _ network: FixtureNetwork, store: TestSecureStore = TestSecureStore(), signedIn: Bool = false, + expired: Bool = false + ) async throws -> EncryptedTransport { + let authority = try CredentialAuthority(apiURL: "http://127.0.0.1:3000", store: store) + if signedIn { + _ = try await authority.install( + accessToken: fixtureJWT(principal: "a", kind: "access", expiry: expired ? 1 : 4_000_000_000), + refreshToken: fixtureJWT(principal: "a", kind: "refresh"), expected: authority.snapshot()) + } + return try EncryptedTransport( + apiURL: "http://127.0.0.1:3000", policy: PCRTrustPolicy(environment: .development), + credentials: authority, store: store, network: network, now: Date.init) + } + func collect(_ response: MapleResponse) async throws -> Data { + var result = Data() + for try await chunk in response.body { result += chunk } + return result + } + let request = MapleRequest(method: .get, target: "/test", auth: .none) + + @Test func encryptedBodyAndSessionPersistence() async throws { + let network = FixtureNetwork(), store = TestSecureStore() + let first = try await make(network, store: store) + #expect(try await collect(first.send(request)) == Data("{\"ok\":true}".utf8)) + let restored = try await make(network, store: store) + _ = try await collect(restored.send(request)) + #expect(await network.handshakes == 1) + let seen = await network.observations + #expect(seen.count == 2 && seen[0].requestID != seen[1].requestID) + #expect(seen.allSatisfy { $0.cacheRoot == nil && $0.credentialKind == nil }) + #expect(!seen[0].bodyPresent) + } + + @Test func anonymousOAuthRequestsNeverCreateOrAttachCacheRoot() async throws { + let network = FixtureNetwork(), store = TestSecureStore() + let transport = try await make(network, store: store) + let (response, binding) = try await transport.beginOAuth( + MapleRequest(method: .post, target: "/auth/google", auth: .none)) + _ = try await collect(response) + _ = try await collect( + transport.completeOAuth( + MapleRequest(method: .post, target: "/auth/google/callback", auth: .none), binding: binding)) + #expect(await network.observations.map(\.target) == ["/auth/google", "/auth/google/callback"]) + #expect(await network.observations.allSatisfy { $0.cacheRoot == nil && $0.credentialKind == nil }) + #expect(try store.read(key: "authority:http://127.0.0.1:3000") == nil) + } + + @Test func cacheWriteFailuresKeepVerifiedSessionAndAuthenticatedNamespaceInMemory() async throws { + let network = FixtureNetwork(), store = TestSecureStore() + let transport = try await make(network, store: store, signedIn: true) + let original = try await transport.credentials.snapshot() + store.setWriteFailure(true) + let authenticated = MapleRequest(method: .get, target: "/protected/user", expectedPrincipalId: "a") + _ = try await collect(transport.send(authenticated)) + _ = try await collect(transport.send(authenticated)) + #expect(await network.handshakes == 1) + let seen = await network.observations + #expect(seen.count == 2 && seen.allSatisfy { $0.credentialKind == "bearer" }) + #expect(seen[0].cacheRoot != nil && seen[0].cacheRoot == seen[1].cacheRoot) + #expect(try await transport.credentials.snapshot() == original) + await #expect(throws: TransportError.persistence) { + try await transport.credentials.install( + accessToken: fixtureJWT(principal: "b", kind: "access"), + refreshToken: fixtureJWT(principal: "b", kind: "refresh"), expected: original) + } + #expect(try await transport.credentials.snapshot() == original) + } + + @Test func sessionCacheReadAndRemovalFailuresDoNotBlockOrRestoreRetiredSession() async throws { + let network = FixtureNetwork(), store = TestSecureStore() + let transport = try await make(network, store: store) + store.setReadFailure(true) + _ = try await collect(transport.send(request)) + store.setReadFailure(false) + store.setWriteFailure(true) + await transport.invalidateSession() + _ = try await collect(transport.send(request)) + #expect(await network.handshakes == 2) + #expect(await network.observations.count == 2) + } + + @Test func onlyExactRecoveryHintAllowsOneResend() async throws { + let network = FixtureNetwork([.hint]), transport = try await make(network) + _ = try await collect(transport.send(request)) + #expect(await network.handshakes == 2) + #expect(await network.observations.count == 2) + let failing = FixtureNetwork([.hint, .hint]), other = try await make(failing) + await #expect(throws: TransportError.untrustedRecoveryHint("session_not_found")) { + try await other.send(request) + } + #expect(await failing.observations.count == 2) + } + + @Test(arguments: [FixtureNetwork.Failure.generic400, .network, .tamper, .truncated, .extra, .secondStart]) + func uncertainOrInvalidResponseNeverResends(_ failure: FixtureNetwork.Failure) async throws { + let network = FixtureNetwork([failure]), transport = try await make(network) + await #expect(throws: (any Error).self) { try await collect(transport.send(request)) } + #expect(await network.observations.count == 1) + } + + @Test func inferenceBudgetCountsTransportRecovery() async throws { + let network = FixtureNetwork([.hint]), transport = try await make(network) + let inference = MapleRequest( + method: .post, target: "/v1/responses", + headers: [openSecretInferenceSendLimitHeader: "1"], auth: .none) + await #expect(throws: TransportError.untrustedRecoveryHint("session_not_found")) { + try await transport.send(inference) + } + #expect(await network.observations.count == 1) + #expect(await network.observations.first?.headers.contains(openSecretInferenceSendLimitHeader) == false) + } + + @Test func refreshCoalescesAndDoesNotInvalidateExistingStream() async throws { + let network = FixtureNetwork(), transport = try await make(network, signedIn: true, expired: true) + let protected = MapleRequest(method: .get, target: "/protected/user", expectedPrincipalId: "a") + async let a = collect(transport.send(protected)) + async let b = collect(transport.send(protected)) + _ = try await (a, b) + #expect(await network.observations.filter { $0.target == "/refresh" }.count == 1) + #expect(await network.observations.first?.credentialKind == "resumption") + #expect(await network.observations.last?.credentialKind == "bearer") + } + + @Test func signOutRejectsLateStreamEvenAfterSameAccountSignIn() async throws { + let network = FixtureNetwork(), transport = try await make(network, signedIn: true) + let response = try await transport.send( + MapleRequest(method: .get, target: "/protected/user", expectedPrincipalId: "a")) + try await transport.credentials.clear() + _ = try await transport.credentials.install( + accessToken: fixtureJWT(principal: "a", kind: "access"), + refreshToken: fixtureJWT(principal: "a", kind: "refresh"), expected: transport.credentials.snapshot()) + await #expect(throws: TransportError.authorityChanged) { try await collect(response) } + } + + @Test func transientRefreshFailurePreservesCredentialsAndNeverSendsOriginal() async throws { + let network = FixtureNetwork(), transport = try await make(network, signedIn: true, expired: true) + await network.setStatus(503) + await #expect(throws: TransportError.requestRejected(status: 503)) { + try await transport.send(MapleRequest(method: .post, target: "/v1/responses")) + } + #expect(try await transport.credentials.authenticatedUserId() == "a") + #expect(await network.observations.map(\.target) == ["/refresh"]) + } + + @Test func definitiveRefreshRejectionClearsOnlyCurrentAuthority() async throws { + let network = FixtureNetwork(), transport = try await make(network, signedIn: true, expired: true) + await network.setStatus(401) + await #expect(throws: TransportError.signedOut) { + try await transport.send(MapleRequest(method: .get, target: "/protected/user")) + } + #expect(try await transport.credentials.authenticatedUserId() == nil) + #expect(await network.observations.count == 1) + } + + @Test func oauthProviderBindingCannotBeReusedForAnotherProvider() async throws { + let network = FixtureNetwork(), transport = try await make(network) + let (response, binding) = try await transport.beginOAuth( + MapleRequest(method: .post, target: "/auth/google", auth: .none)) + _ = try await collect(response) + await #expect(throws: TransportError.invalidSession) { + try await transport.completeOAuth( + MapleRequest(method: .post, target: "/auth/apple/callback", auth: .none), binding: binding) + } + #expect(await network.observations.count == 1) + } + + @Test func nativeHandoffIsPinnedOneUseAndRequiresConfirmation() async throws { + let network = FixtureNetwork(), transport = try await make(network) + let prepared = try await transport.prepareNativeOAuthHandoff() + let grant = try NativeOAuthHandoffGrant(validating: "e30.e30.YQ") + let confirmation = try await transport.redeemNativeOAuthHandoff(prepared, grant: grant) + #expect(try await transport.credentials.authenticatedUserId() == nil) + #expect(await network.observations.first?.requestID == prepared.requestID) + #expect(await network.observations.first?.cacheRoot == nil) + await #expect(throws: TransportError.invalidSession) { + try await transport.redeemNativeOAuthHandoff(prepared, grant: grant) + } + _ = try await transport.confirmNativeOAuthHandoff(confirmation) + #expect(try await transport.credentials.authenticatedUserId() == "a") + await #expect(throws: TransportError.authorityChanged) { + try await transport.confirmNativeOAuthHandoff(confirmation) + } + } +} diff --git a/sdk/swift/Tests/MapleSDKTests/Transport/FixtureNetwork.swift b/sdk/swift/Tests/MapleSDKTests/Transport/FixtureNetwork.swift new file mode 100644 index 000000000..87f360c48 --- /dev/null +++ b/sdk/swift/Tests/MapleSDKTests/Transport/FixtureNetwork.swift @@ -0,0 +1,140 @@ +import CryptoKit +import Foundation +@testable import MapleSDK + +actor FixtureNetwork: HTTPNetworking { + enum Failure: Sendable { case hint, generic400, tamper, truncated, extra, secondStart, network } + struct Observation: Sendable { + let target: String + let credentialKind: String? + let cacheRoot: String? + let requestID: String + let headers: [String] + let bodyPresent: Bool + } + private var sessions: [String: SessionKeys] = [:] + private(set) var handshakes = 0 + private(set) var observations: [Observation] = [] + private var failures: [Failure] + private var responseStatus = 200 + private var expireNext = false + init(_ failures: [Failure] = []) { self.failures = failures } + func setStatus(_ status: Int) { responseStatus = status } + func expireNextAccess() { expireNext = true } + + func open(_ request: URLRequest) async throws -> HTTPConnection { + if request.url?.path == "/v2/session" { return try handshake(request) } + guard let id = request.value(forHTTPHeaderField: "x-session-id"), let keys = sessions[id], + let body = request.httpBody, body.count >= 32 + else { throw TransportError.invalidSession } + let requestID = Data(body.prefix(16)) + let key = try TransportCrypto.subkey( + keys.request, label: "request-subkey/v1", session: keys.id, request: requestID) + let box = try ChaChaPoly.SealedBox( + nonce: ChaChaPoly.Nonce(data: Data(count: 12)), + ciphertext: body.dropFirst(16).dropLast(16), tag: body.suffix(16)) + let plaintext = try ChaChaPoly.open( + box, using: key, + authenticating: TransportCrypto.domain("request-record/v1") + keys.id + requestID) + let length = plaintext.prefix(4).reduce(0) { $0 * 256 + Int($1) } + let metadata = try JSONSerialization.jsonObject(with: plaintext.subdata(in: 4..<(4 + length))) as! [String: Any] + let target = metadata["target"] as! String + observations.append( + Observation( + target: target, credentialKind: (metadata["credential"] as? [String: String])?["kind"], + cacheRoot: metadata["cache_namespace_root"] as? String, requestID: requestID.lowercaseHex, + headers: (metadata["headers"] as! [[String: String]]).compactMap { $0["name"] }, + bodyPresent: metadata["body_present"] as! Bool)) + let failure = failures.isEmpty ? nil : failures.removeFirst() + if failure == .network { throw URLError(.networkConnectionLost) } + if failure == .hint { + return connection( + status: 400, + headers: ["x-opensecret-error-contract": "1", "x-opensecret-error-code": "session_not_found"], + data: Data()) + } + if failure == .generic400 { return connection(status: 400, headers: [:], data: Data()) } + let payload: Data + if target == "/refresh" || target == "/auth/native-handoff/redeem" || target == "/auth/google/callback" { + payload = try JSONSerialization.data(withJSONObject: [ + "id": "a", + "access_token": fixtureJWT(principal: "a", kind: "access"), + "refresh_token": fixtureJWT(principal: "a", kind: "refresh"), + ]) + } else { + payload = Data("{\"ok\":true}".utf8) + } + let expired = expireNext && target != "/refresh" + if expired { expireNext = false } + let responseHeaders = + expired + ? [ + ["name": "x-opensecret-error-contract", "value": "1"], + ["name": "x-opensecret-error-code", "value": "access_token_expired"], + ] : [] + let start = + Data([1]) + + (try JSONSerialization.data(withJSONObject: [ + "status": expired ? 401 : responseStatus, "headers": responseHeaders, + ])) + var records = [start, Data([2]) + payload, Data([3])] + if failure == .secondStart { records.insert(start, at: 1) } + var framed = Data() + for (index, record) in records.enumerated() { + let cipher = try sealResponse(record, keys: keys, request: requestID, sequence: UInt64(index)) + framed += UInt32(cipher.count).bigEndianData + cipher + } + if failure == .truncated { framed.removeLast(5) } + if failure == .tamper { framed[framed.count - 1] ^= 1 } + if failure == .extra { framed.append(0) } + return connection(status: 200, headers: ["content-type": "application/octet-stream"], data: framed) + } + + private func handshake(_ request: URLRequest) throws -> HTTPConnection { + handshakes += 1 + let fields = try JSONSerialization.jsonObject(with: request.httpBody!) as! [String: Any] + let challenge = Data(base64Encoded: fields["challenge"] as! String)! + let client = Data(base64Encoded: fields["client_public_key"] as! String)! + let secret = Curve25519.KeyAgreement.PrivateKey() + let server = secret.publicKey.rawRepresentation + let shared = try secret.sharedSecretFromKeyAgreement( + with: Curve25519.KeyAgreement.PublicKey(rawRepresentation: client)) + let keys = try TransportCrypto.derive( + shared: shared.withUnsafeBytes { Data($0) }, challenge: challenge, client: client, server: server) + sessions[keys.id.lowercaseHex] = keys + let payload = TrustCBOR.map([ + .init(.text("nonce"), .bytes(challenge)), .init(.text("public_key"), .bytes(server)), + .init(.text("user_data"), .bytes(TransportCrypto.domain("session/v1/client-public-key") + client)), + ]).encode() + let document = TrustCBOR.array([.bytes(Data()), .map([]), .bytes(payload), .bytes(Data())]).encode() + let json = try JSONSerialization.data(withJSONObject: [ + "version": 2, "session_id": keys.id.lowercaseHex, + "expires_in_seconds": 3600, "attestation_document": document.base64EncodedString(), + ]) + return connection(status: 200, headers: ["content-type": "application/json"], data: json) + } +} + +func sealResponse(_ data: Data, keys: SessionKeys, request: Data, sequence: UInt64) throws -> Data { + let key = try TransportCrypto.subkey(keys.response, label: "response-subkey/v1", session: keys.id, request: request) + let box = try ChaChaPoly.seal( + data, using: key, + nonce: ChaChaPoly.Nonce(data: Data(count: 4) + sequence.bigEndianData), + authenticating: TransportCrypto.domain("response-record/v1") + keys.id + request + sequence.bigEndianData) + return box.ciphertext + box.tag +} + +func connection(status: Int, headers: [String: String], data: Data) -> HTTPConnection { + let source = FixtureBytes(data) + return HTTPConnection(status: status, headers: headers, read: { await source.read($0) }, cancel: {}) +} + +private actor FixtureBytes { + var data: Data + init(_ data: Data) { self.data = data } + func read(_ maximum: Int) -> Data { + let size = min(maximum, data.count) + defer { data = Data(data.dropFirst(size)) } + return Data(data.prefix(size)) + } +} diff --git a/sdk/swift/Tests/MapleSDKTests/Transport/HandshakeBoundaryTests.swift b/sdk/swift/Tests/MapleSDKTests/Transport/HandshakeBoundaryTests.swift new file mode 100644 index 000000000..37230ef2f --- /dev/null +++ b/sdk/swift/Tests/MapleSDKTests/Transport/HandshakeBoundaryTests.swift @@ -0,0 +1,68 @@ +import Foundation +import Testing +@testable import MapleSDK + +@Suite struct HandshakeBoundaryTests { + @Test(arguments: ["https://localhost", "https://127.0.0.1", "https://[::1]"]) + func httpsLoopbackMustVerifyRealAttestation(_ endpoint: String) async throws { + let store = TestSecureStore(), network = FixtureNetwork() + let authority = try CredentialAuthority(apiURL: endpoint, store: store) + let transport = try EncryptedTransport( + apiURL: endpoint, policy: PCRTrustPolicy(environment: .development), + credentials: authority, store: store, network: network, now: Date.init) + #expect(try !APIEndpoint(endpoint).isLoopback) + await #expect(throws: (any Error).self) { + try await transport.send(MapleRequest(method: .get, target: "/test", auth: .none)) + } + #expect(await network.handshakes == 1) + #expect(await network.observations.isEmpty) + } + + @Test func nativeAttemptAndConfirmationExpireAtFifteenMinutes() async throws { + let clock = FixtureClock(), store = TestSecureStore(), network = FixtureNetwork() + let authority = try CredentialAuthority(apiURL: "http://localhost", store: store) + let transport = try EncryptedTransport( + apiURL: "http://localhost", policy: PCRTrustPolicy(environment: .development), + credentials: authority, store: store, network: network, now: { clock.now() }) + let prepared = try await transport.prepareNativeOAuthHandoff() + #expect(prepared.expiresAt.timeIntervalSince(clock.now()) == 900) + clock.advance(901) + let grant = try NativeOAuthHandoffGrant(validating: "e30.e30.YQ") + await #expect(throws: TransportError.invalidSession) { + try await transport.redeemNativeOAuthHandoff(prepared, grant: grant) + } + #expect(await network.observations.isEmpty) + let second = try await transport.prepareNativeOAuthHandoff() + let confirmation = try await transport.redeemNativeOAuthHandoff(second, grant: grant) + clock.advance(901) + await #expect(throws: TransportError.authorityChanged) { + try await transport.confirmNativeOAuthHandoff(confirmation) + } + #expect(try await authority.authenticatedUserId() == nil) + } + + @Test func ordinaryHeadersAreCanonicalizedAndDuplicatesRejected() async throws { + let helper = EncryptedTransportTests(), network = FixtureNetwork() + let transport = try await helper.make(network) + _ = try await helper.collect( + transport.send( + MapleRequest( + method: .post, target: "/test", + headers: ["Content-Type": "application/json"], auth: .none))) + #expect(await network.observations.first?.headers == ["content-type"]) + await #expect(throws: TransportError.invalidEnvelope) { + try await transport.send( + MapleRequest( + method: .post, target: "/test", + headers: ["Content-Type": "application/json", "content-type": "text/plain"], auth: .none)) + } + #expect(await network.observations.count == 1) + } +} + +private final class FixtureClock: @unchecked Sendable { + private let lock = NSLock() + private var date = Date() + func now() -> Date { lock.lock(); defer { lock.unlock() }; return date } + func advance(_ seconds: TimeInterval) { lock.lock(); defer { lock.unlock() }; date.addTimeInterval(seconds) } +} diff --git a/sdk/swift/Tests/MapleSDKTests/Transport/InferenceBudgetHeaderTests.swift b/sdk/swift/Tests/MapleSDKTests/Transport/InferenceBudgetHeaderTests.swift new file mode 100644 index 000000000..154338a6e --- /dev/null +++ b/sdk/swift/Tests/MapleSDKTests/Transport/InferenceBudgetHeaderTests.swift @@ -0,0 +1,47 @@ +import Foundation +import Testing +@testable import MapleSDK + +@Suite struct InferenceBudgetHeaderTests { + private func transport(_ network: FixtureNetwork) throws -> EncryptedTransport { + let store = TestSecureStore() + let credentials = try CredentialAuthority(apiURL: "http://127.0.0.1:3000", store: store) + return try EncryptedTransport( + apiURL: "http://127.0.0.1:3000", policy: PCRTrustPolicy(environment: .development), + credentials: credentials, store: store, network: network, now: Date.init) + } + + @Test(arguments: [ + "X-OpenSecret-Client-Inference-Send-Limit", "X-OPENSECRET-CLIENT-INFERENCE-SEND-LIMIT", + openSecretInferenceSendLimitHeader, + ]) + func caseInsensitiveBudgetIsEnforcedAndNeverForwarded(_ header: String) async throws { + let network = FixtureNetwork([.hint]) + let client = try transport(network) + let request = MapleRequest(method: .post, target: "/v1/responses", headers: [header: "1"], auth: .none) + await #expect(throws: TransportError.untrustedRecoveryHint("session_not_found")) { + try await client.send(request) + } + let observations = await network.observations + #expect(observations.count == 1) + #expect( + observations.allSatisfy { observation in + !observation.headers.contains { + $0.caseInsensitiveCompare(openSecretInferenceSendLimitHeader) == .orderedSame + } + }) + } + + @Test(arguments: ["1", "2"]) + func caseDuplicateBudgetHeadersRejectBeforeDispatch(_ second: String) async throws { + let network = FixtureNetwork() + let client = try transport(network) + let request = MapleRequest( + method: .post, target: "/v1/responses", + headers: [openSecretInferenceSendLimitHeader: "1", "X-OpenSecret-Client-Inference-Send-Limit": second], + auth: .none) + await #expect(throws: TransportError.invalidEnvelope) { try await client.send(request) } + #expect(await network.observations.isEmpty) + #expect(await network.handshakes == 0) + } +} diff --git a/sdk/swift/Tests/MapleSDKTests/Transport/LiveTransportTests.swift b/sdk/swift/Tests/MapleSDKTests/Transport/LiveTransportTests.swift new file mode 100644 index 000000000..f12574d0d --- /dev/null +++ b/sdk/swift/Tests/MapleSDKTests/Transport/LiveTransportTests.swift @@ -0,0 +1,86 @@ +import Foundation +import Testing +@testable import MapleSDK + +@Suite(.enabled(if: ProcessInfo.processInfo.environment["MAPLE_SWIFT_DEV_INTEGRATION"] == "1")) +struct LiveTransportTests { + @Test func devSessionRenewalAndActualMissingSessionRecovery() async throws { + let url = "https://enclave.secretgpt.ai" + let storage = TestSecureStore(), network = DevRecoveryNetwork() + let policy = try PCRTrustPolicy(environment: .development) + let authority = try CredentialAuthority(apiURL: url, store: storage) + func makeTransport() throws -> EncryptedTransport { + try EncryptedTransport( + apiURL: url, policy: policy, credentials: authority, + store: storage, network: network, now: Date.init) + } + func consume(_ transport: EncryptedTransport) async throws { + let response = try await transport.send(MapleRequest(method: .get, target: "/v1/models", auth: .none)) + #expect([200, 401].contains(response.status)) + for try await _ in response.body {} + } + let first = try makeTransport() + // Send one anonymous request with an unknown outer session identifier. + // The actual enclave must return its exact, unauthenticated recovery hint; + // the retry then establishes fresh verified keys before sending again. + try await consume(first) + #expect(await network.handshakes == 2) + #expect(await network.requests == 2) + #expect(await network.receivedMissingSessionHint) + let storageKey = "session:" + hashSecret(url + "\n" + policy.cacheIdentity) + let storedData = try #require(try storage.read(key: storageKey)) + let saved = try JSONDecoder().decode(AttestedSession.self, from: storedData) + let expired = AttestedSession( + version: saved.version, apiURL: saved.apiURL, policyIdentity: saved.policyIdentity, + keys: saved.keys, routingKey: saved.routingKey, expiresAt: .distantPast) + try storage.write(JSONEncoder().encode(expired), key: storageKey) + try await consume(makeTransport()) + #expect(await network.handshakes == 3) + #expect(await network.requests == 3) + } + + @Test func devEnclaveEncryptsAndAuthenticatesLogicalResponses() async throws { + let storage = TestSecureStore() + let client = try MapleClient( + apiURL: "https://enclave.secretgpt.ai", + clientID: "ba5a14b5-d915-47b1-b7b1-afda52bc5fc6", policy: PCRTrustPolicy(environment: .development), + store: storage) + // An anonymous read proves real attestation, X25519, request sealing, + // incremental decryption and authenticated finality without creating accounts. + let response = try await client.transport.send(MapleRequest(method: .get, target: "/v1/models", auth: .none)) + #expect([200, 401].contains(response.status)) + var count = 0 + for try await chunk in response.body { + count += chunk.count + #expect(count <= 1024 * 1024) + } + #expect(count > 0) + // Reserve a real attested native handoff without minting or redeeming a grant. + let prepared = try await client.transport.prepareNativeOAuthHandoff() + #expect(prepared.sessionID.count == 32 && prepared.requestID.count == 32) + await client.transport.cancelNativeOAuthHandoff() + } +} + +private actor DevRecoveryNetwork: HTTPNetworking { + let underlying = URLSessionNetworking() + private(set) var handshakes = 0 + private(set) var requests = 0 + private(set) var receivedMissingSessionHint = false + func open(_ request: URLRequest) async throws -> HTTPConnection { + if request.url?.path == "/v2/session" { handshakes += 1 } + var request = request + if request.url?.path == "/v2/request" { + requests += 1 + if requests == 1 { request.setValue(String(repeating: "0", count: 32), forHTTPHeaderField: "x-session-id") } + } + let response = try await underlying.open(request) + if requests == 1, request.url?.path == "/v2/request" { + receivedMissingSessionHint = + response.status == 400 + && response.headers["x-opensecret-error-contract"] == "1" + && response.headers["x-opensecret-error-code"] == "session_not_found" + } + return response + } +} diff --git a/sdk/swift/Tests/MapleSDKTests/Transport/NativeHandoffBoundaryTests.swift b/sdk/swift/Tests/MapleSDKTests/Transport/NativeHandoffBoundaryTests.swift new file mode 100644 index 000000000..4553cc0cc --- /dev/null +++ b/sdk/swift/Tests/MapleSDKTests/Transport/NativeHandoffBoundaryTests.swift @@ -0,0 +1,123 @@ +import Foundation +import Testing +@testable import MapleSDK + +@Suite struct NativeHandoffBoundaryTests { + @Test(arguments: [FixtureNetwork.Failure.hint, .network, .tamper, .truncated]) + func failedNativeRedemptionNeverRetriesAndRetiresSession(_ failure: FixtureNetwork.Failure) async throws { + let network = FixtureNetwork([failure]) + let transport = try await EncryptedTransportTests().make(network) + let prepared = try await transport.prepareNativeOAuthHandoff() + let grant = try NativeOAuthHandoffGrant(validating: "e30.e30.YQ") + + await #expect(throws: (any Error).self) { + try await transport.redeemNativeOAuthHandoff(prepared, grant: grant) + } + #expect(await network.handshakes == 1) + #expect(await network.observations.count == 1) + #expect(try await transport.credentials.authenticatedUserId() == nil) + await #expect(throws: TransportError.invalidSession) { + try await transport.redeemNativeOAuthHandoff(prepared, grant: grant) + } + #expect(await network.observations.count == 1) + + let replacement = try await transport.prepareNativeOAuthHandoff() + #expect(await network.handshakes == 2) + #expect(replacement.sessionID != prepared.sessionID) + #expect(replacement.requestID != prepared.requestID) + let confirmation = try await transport.redeemNativeOAuthHandoff(replacement, grant: grant) + #expect(confirmation.principalID == "a") + #expect(try await transport.credentials.authenticatedUserId() == nil) + } + + @Test(arguments: [false, true]) + func replacedOrCancelledSessionRejectsPreparedHandoffBeforeDispatch(cancel: Bool) async throws { + let network = FixtureNetwork() + let transport = try await EncryptedTransportTests().make(network) + let prepared = try await transport.prepareNativeOAuthHandoff() + if cancel { + await transport.cancelNativeOAuthHandoff() + } else { + await transport.invalidateSession() + } + let replacement = try await transport.prepareNativeOAuthHandoff() + #expect(replacement.sessionID != prepared.sessionID) + let grant = try NativeOAuthHandoffGrant(validating: "e30.e30.YQ") + await #expect(throws: TransportError.invalidSession) { + try await transport.redeemNativeOAuthHandoff(prepared, grant: grant) + } + #expect(await network.handshakes == 2) + #expect(await network.observations.isEmpty) + #expect(try await transport.credentials.authenticatedUserId() == nil) + + let confirmation = try await transport.redeemNativeOAuthHandoff(replacement, grant: grant) + #expect(confirmation.principalID == "a") + #expect(await network.observations.count == 1) + } + + @Test(arguments: ["a", "replacement-account"]) + func nativeRedemptionRejectsAuthorityChangedInFlight(principal: String) async throws { + let network = DelayedNativeHandoffNetwork(), store = TestSecureStore() + let authority = try CredentialAuthority(apiURL: "http://127.0.0.1:3000", store: store) + let transport = try EncryptedTransport( + apiURL: "http://127.0.0.1:3000", policy: PCRTrustPolicy(environment: .development), + credentials: authority, store: store, network: network, now: Date.init) + let prepared = try await transport.prepareNativeOAuthHandoff() + let grant = try NativeOAuthHandoffGrant(validating: "e30.e30.YQ") + let redemption = Task { try await transport.redeemNativeOAuthHandoff(prepared, grant: grant) } + await network.waitForRedemption() + + let replacement: CredentialSnapshot + do { + replacement = try await authority.install( + accessToken: fixtureJWT(principal: principal, kind: "access"), + refreshToken: fixtureJWT(principal: principal, kind: "refresh"), expected: authority.snapshot()) + } catch { + await network.releaseRedemption() + redemption.cancel() + throw error + } + await network.releaseRedemption() + await #expect(throws: TransportError.authorityChanged) { try await redemption.value } + #expect(try await authority.snapshot() == replacement) + #expect(try await authority.authenticatedUserId() == principal) + #expect(await network.underlying.observations.count == 1) + #expect(await network.underlying.handshakes == 1) + + // Returning to anonymous state must use new keys after the rejected response. + try await authority.clear(expected: replacement) + let next = try await transport.prepareNativeOAuthHandoff() + #expect(next.sessionID != prepared.sessionID) + #expect(await network.underlying.handshakes == 2) + } +} + +/// Hold the encrypted reply after the backend fixture observes redemption, so +/// credential replacement is ordered explicitly without timing-based sleeps. +private actor DelayedNativeHandoffNetwork: HTTPNetworking { + let underlying = FixtureNetwork() + private var observedRedemption = false + private var arrival: CheckedContinuation? + private var release: CheckedContinuation? + + func open(_ request: URLRequest) async throws -> HTTPConnection { + let response = try await underlying.open(request) + if request.url?.path == "/v2/request" { + observedRedemption = true + arrival?.resume() + arrival = nil + await withCheckedContinuation { release = $0 } + } + return response + } + + func waitForRedemption() async { + if observedRedemption { return } + await withCheckedContinuation { arrival = $0 } + } + + func releaseRedemption() { + release?.resume() + release = nil + } +} diff --git a/sdk/swift/Tests/MapleSDKTests/Transport/TestSecureStore.swift b/sdk/swift/Tests/MapleSDKTests/Transport/TestSecureStore.swift new file mode 100644 index 000000000..6db15c86e --- /dev/null +++ b/sdk/swift/Tests/MapleSDKTests/Transport/TestSecureStore.swift @@ -0,0 +1,43 @@ +import Foundation +@testable import MapleSDK + +final class TestSecureStore: SecureStore, @unchecked Sendable { + private let lock = NSLock() + private var values: [String: Data] = [:] + private var failWrites = false + private var failReads = false + func setWriteFailure(_ fail: Bool) { lock.withLock { failWrites = fail } } + func setReadFailure(_ fail: Bool) { lock.withLock { failReads = fail } } + func read(key: String) throws -> Data? { + try lock.withLock { + if failReads { throw TransportError.persistence } + return values[key] + } + } + func write(_ data: Data, key: String) throws { + try lock.withLock { + if failWrites { throw TransportError.persistence } + values[key] = data + } + } + func remove(key: String) throws { + try lock.withLock { + if failWrites { throw TransportError.persistence } + values.removeValue(forKey: key) + } + } +} + +func fixtureJWT(principal: String, kind: String, expiry: Int = 4_000_000_000, format: Int = 2) throws -> String { + func base64URL(_ value: Data) -> String { + value.base64EncodedString().replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") + } + let claims: [String: Any] = [ + "sub": principal, "exp": expiry, "tf": format, + "aud": "urn:opensecret:internal:transport-v2:user:\(kind)-token", + ] + return base64URL(Data("{\"alg\":\"HS256\"}".utf8)) + "." + + base64URL(try JSONSerialization.data(withJSONObject: claims)) + "." + + base64URL(Data("synthetic-signature".utf8)) +} diff --git a/sdk/swift/Tests/MapleSDKTests/Transport/TransportCryptoTests.swift b/sdk/swift/Tests/MapleSDKTests/Transport/TransportCryptoTests.swift new file mode 100644 index 000000000..1dd7ff66d --- /dev/null +++ b/sdk/swift/Tests/MapleSDKTests/Transport/TransportCryptoTests.swift @@ -0,0 +1,51 @@ +import Foundation +import Testing +@testable import MapleSDK + +@Suite struct TransportCryptoTests { + func keys(_ byte: UInt8 = 0x44) throws -> SessionKeys { + try TransportCrypto.derive( + shared: Data(repeating: byte, count: 32), + challenge: Data(repeating: 0x11, count: 32), + client: Data(repeating: 0x22, count: 32), server: Data(repeating: 0x33, count: 32)) + } + + @Test func matchesBackendAndRustVectors() throws { + let session = try keys() + #expect(session.id.lowercaseHex == "f7258fb103137c612baab47ced4a5a02") + let encrypted = try TransportCrypto.sealRequest( + Data("vector plaintext".utf8), keys: session, + request: Data(repeating: 0x55, count: 16)) + #expect( + encrypted.lowercaseHex + == "55555555555555555555555555555555671f5c411205cb00f769e6b2705052b795e91f44516fc6165e16a152e686b209") + let fixture = Data([ + 0x25, 0xa2, 0xd5, 0xed, 0x89, 0x86, 0x4b, 0xd7, 0xb5, 0xe1, 0x3c, 0x83, 0xeb, 0x49, 0xb1, 0xf3, 0x14, 0xa7, + 0x0a, 0xbf, 0x8b, 0xd7, 0xe8, 0x71, 0xb7, 0x06, 0xbb, 0x67, 0x68, 0xc9, 0xe1, + ]) + let request = Data(repeating: 0x66, count: 16) + #expect( + try TransportCrypto.openResponse(fixture, keys: session, request: request, sequence: 0) + == Data("vector response".utf8)) + #expect(throws: TransportError.authentication) { + try TransportCrypto.openResponse(fixture, keys: session, request: request, sequence: 1) + } + #expect(throws: TransportError.authentication) { + try TransportCrypto.openResponse(fixture, keys: keys(0x45), request: request, sequence: 0) + } + #expect(throws: TransportError.authentication) { + try TransportCrypto.openResponse( + fixture, keys: session, request: Data(repeating: 0x67, count: 16), sequence: 0) + } + } + + @Test func rejectsNoncontributoryExchange() { + #expect(throws: TransportError.authentication) { try keys(0) } + } + + @Test func rejectsNoncanonicalBase64() { + #expect(Data(canonicalBase64: "AB==", count: 1) == nil) + #expect(Data(canonicalBase64: "AA==", count: 1) == Data([0])) + #expect(Data(canonicalBase64: "AA", count: 1) == nil) + } +} diff --git a/sdk/swift/Tests/MapleSDKTests/Transport/WireProtocolTests.swift b/sdk/swift/Tests/MapleSDKTests/Transport/WireProtocolTests.swift new file mode 100644 index 000000000..af17e9e5f --- /dev/null +++ b/sdk/swift/Tests/MapleSDKTests/Transport/WireProtocolTests.swift @@ -0,0 +1,59 @@ +import Foundation +import Testing +@testable import MapleSDK + +@Suite struct WireProtocolTests { + @Test func absentAndEmptyBodiesAreDistinct() throws { + func metadata(_ body: Data?) throws -> [String: Any] { + let bytes = try WireProtocol.envelope( + method: "POST", target: "/test", headers: [], body: body, credential: nil, cacheRoot: nil) + return try JSONSerialization.jsonObject(with: Data(bytes.dropFirst(4))) as! [String: Any] + } + #expect(try metadata(nil)["body_present"] as? Bool == false) + #expect(try metadata(Data())["body_present"] as? Bool == true) + #expect(try metadata(nil)["credential"] is NSNull) + } + + @Test(arguments: ["", "//evil.test/path", "https://evil.test/path", "/x#fragment", "/x\\y", "/x\ny", "/💬"]) + func rejectsMalformedTargets(_ target: String) { + #expect(throws: TransportError.invalidEnvelope) { + try WireProtocol.envelope( + method: "GET", target: target, headers: [], body: nil, credential: nil, cacheRoot: nil) + } + } + + @Test func deniesEveryGatewayHeaderAndControlCharacters() { + for name in WireProtocol.forbiddenHeaders.union(["Content-Type", "", "bad name"]) { + #expect(throws: TransportError.invalidEnvelope) { + try WireProtocol.envelope( + method: "POST", target: "/test", headers: [WireHeader(name: name, value: "x")], body: nil, + credential: nil, cacheRoot: nil) + } + } + #expect(throws: TransportError.invalidEnvelope) { + try WireProtocol.validateHeaders([WireHeader(name: "x-test", value: "a\r\nb")], maximum: 32) + } + #expect(throws: TransportError.invalidEnvelope) { + try WireProtocol.validateHeaders( + Array(repeating: WireHeader(name: "x-test", value: "ok"), count: 65), maximum: 64) + } + } + + @Test func recordsHaveStrictMetadataAndTerminalShape() throws { + func start(_ object: [String: Any]) throws -> WireProtocol.Record { + try WireProtocol.decode(Data([1]) + JSONSerialization.data(withJSONObject: object)) + } + #expect(try start(["status": 200, "headers": []]) == .start(200, [])) + for status in [true as Any, 199, 600, 200.5, "200"] { + #expect(throws: (any Error).self) { try start(["status": status, "headers": []]) } + } + #expect(throws: TransportError.invalidEnvelope) { try start(["status": 200, "headers": [], "extra": 1]) } + #expect(throws: TransportError.invalidFrame) { try WireProtocol.decode(Data([3, 0])) } + #expect(throws: TransportError.invalidFrame) { try WireProtocol.decode(Data([9])) } + #expect(throws: TransportError.invalidFrame) { + try WireProtocol.decode(Data([4]) + Data("{\"code\":\"private details\"}".utf8)) + } + #expect( + try WireProtocol.decode(Data([4]) + Data("{\"code\":\"stream_failed\"}".utf8)) == .error("stream_failed")) + } +} diff --git a/sdk/swift/Tests/MapleSDKTests/Trust/AttestationTests.swift b/sdk/swift/Tests/MapleSDKTests/Trust/AttestationTests.swift new file mode 100644 index 000000000..8feff9456 --- /dev/null +++ b/sdk/swift/Tests/MapleSDKTests/Trust/AttestationTests.swift @@ -0,0 +1,171 @@ +import CryptoKit +import Foundation +import Testing +@testable import MapleSDK + +struct AttestationTests { + static let signedAt = Date(timeIntervalSince1970: 1_730_141_656.206) + static let notBefore = Date(timeIntervalSince1970: 1_730_141_295) + static let notAfter = notBefore.addingTimeInterval(3 * 3_600 + 3) + static let nonce = Data("cc6b95ef-a0d7-477d-90f2-36cc088d2449".utf8) + + static func fixture() throws -> String { + let url = try #require( + Bundle.module.url(forResource: "nitro_attestation_document_2024-10-28", withExtension: "b64")) + return try String(contentsOf: url, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func verify(at time: Date = signedAt) throws -> AttestationDocument { + try AttestationVerifier().verify(base64: Self.fixture(), nonce: Self.nonce, deviceTime: time) + } + + @Test func realNitroDocumentAndPinnedRootVerify() throws { + let document = try verify() + #expect(document.moduleID == "i-06c79bf817127030a-enc0192d3d4945e0432") + #expect(document.timestamp == 1_730_141_656_206) + #expect(document.digest == "SHA384") + #expect(document.nonce == Self.nonce) + let validity = try CertificateValidity(der: document.certificate) + #expect(abs(validity.notBefore.timeIntervalSince(Self.notBefore)) < 0.001) + #expect(abs(validity.notAfter.timeIntervalSince(Self.notAfter)) < 0.001) + let fingerprint = SHA256.hash(data: document.caBundle[0]).map { String(format: "%02x", $0) }.joined() + #expect(fingerprint == "641a0321a3e244efe456463195d606317ed7cdcc3c1756e09893f3c68f79bb5b") + } + + @Test func notBeforeLeewayIsFiveMinutesOnly() throws { + for seconds in [1.0, 60, 300] { _ = try verify(at: Self.notBefore.addingTimeInterval(-seconds)) } + do { + _ = try verify(at: Self.notBefore.addingTimeInterval(-301)) + Issue.record("Accepted certificate before clock leeway") + } catch let error as AttestationClockSkewError { + #expect(error.certificateIndex == 0) + #expect(error.deviceClockLikelyWrong) + #expect(error.errorDescription?.contains("about 11 minutes behind the secure enclave") == true) + #expect(error.errorDescription?.contains("looks not yet valid") == true) + #expect(error.errorDescription?.contains("date, time and time zone settings") == true) + } + } + + @Test func notAfterIsStrictAndClockErrorsMatchReference() throws { + _ = try verify(at: Self.notAfter) + for days in [-3.0, 3] { + do { + _ = try verify(at: Self.signedAt.addingTimeInterval(days * 86_400)) + Issue.record("Accepted expired or future certificate") + } catch let error as AttestationClockSkewError { + #expect( + error.errorDescription?.contains( + "about 3 days \(days > 0 ? "ahead of" : "behind") the secure enclave") == true) + #expect(error.enclaveTime == Self.signedAt) + } + } + #expect(throws: AttestationClockSkewError.self) { try verify(at: Self.notAfter.addingTimeInterval(0.001)) } + #expect(throws: AttestationClockSkewError.self) { try verify(at: Date()) } + } + + @Test func nonceIsExactAndCannotBeOmitted() throws { + for nonce in [Data(), Data(repeating: 0, count: Self.nonce.count), Self.nonce + Data([0])] { + #expect(throws: TrustError.nonceMismatch) { + try AttestationVerifier().verify(base64: Self.fixture(), nonce: nonce, deviceTime: Self.signedAt) + } + } + } + + private func mutate(_ field: String, value: TrustCBOR) throws -> String { + let encoded = try #require(Data(base64Encoded: Self.fixture())) + var envelope = try #require(TrustCBOR.decode(encoded).items) + let payload = try #require(envelope[2].data) + var fields = try #require(TrustCBOR.decode(payload).pairs) + fields.removeAll { $0.key == .text(field) } + fields.append(.init(.text(field), value)) + envelope[2] = .bytes(TrustCBOR.map(fields).encode()) + return TrustCBOR.array(envelope).encode().base64EncodedString() + } + + @Test func tamperedTimestampFailsSignatureBeforeClockDiagnosis() throws { + let forged = try mutate("timestamp", value: .unsigned(1_800_000_000_000)) + #expect(throws: TrustError.documentSignature) { + try AttestationVerifier().verify(base64: forged, nonce: Self.nonce, deviceTime: Self.signedAt) + } + } + + @Test func wrongRootAndTamperedCertificateFailClosed() throws { + let document = try verify() + var root = document.caBundle[0] + root[root.count - 1] ^= 1 + let forged = try mutate( + "cabundle", value: .array([.bytes(root)] + document.caBundle.dropFirst().map { .bytes($0) })) + #expect(throws: TrustError.certificateChain) { + try AttestationVerifier().verify(base64: forged, nonce: Self.nonce, deviceTime: Self.signedAt) + } + let truncated = try mutate("certificate", value: .bytes(document.certificate.dropLast())) + #expect(throws: (any Error).self) { + try AttestationVerifier().verify(base64: truncated, nonce: Self.nonce, deviceTime: Self.signedAt) + } + } + + @Test func coseRejectsAlgorithmSubstitutionMalformedAndTrailingData() throws { + let encoded = try #require(Data(base64Encoded: Self.fixture())) + var envelope = try #require(TrustCBOR.decode(encoded).items) + envelope[0] = .bytes(TrustCBOR.map([.init(.unsigned(1), .negative(-7))]).encode()) + #expect(throws: TrustError.unsupportedAlgorithm) { + try COSEDocument(encoded: TrustCBOR.array(envelope).encode()) + } + #expect(throws: TrustError.malformedDocument) { try COSEDocument(encoded: encoded + Data([0])) } + #expect(throws: TrustError.malformedDocument) { try COSEDocument(encoded: Data(repeating: 0, count: 65_537)) } + #expect(throws: TrustError.malformedDocument) { try COSEDocument(encoded: .init([0x9f, 0xff])) } + #expect(throws: TrustError.malformedDocument) { + try COSEDocument(encoded: TrustCBOR.tag(17, .array([])).encode()) + } + _ = try COSEDocument(encoded: TrustCBOR.tag(18, TrustCBOR.decode(encoded)).encode()) + } + + @Test func cborRejectsDuplicateKeysEvenWithDifferentIntegerEncoding() throws { + #expect(throws: TrustError.malformedDocument) { try TrustCBOR.decode(Data([0xa2, 1, 2, 0x18, 1, 3])) } + #expect(throws: TrustError.malformedDocument) { try TrustCBOR.decode(Data([0x61, 0xff])) } + #expect(throws: TrustError.malformedDocument) { try TrustCBOR.decode(Data([0x58, 0xff])) } + #expect(throws: TrustError.malformedDocument) { + try TrustCBOR.decode(Data(repeating: 0x81, count: 18) + Data([0])) + } + } + + @Test func currentNitroIndefiniteContainersStayBoundedAndRejectDuplicateKeys() throws { + #expect(try TrustCBOR.decode(Data([0xbf, 1, 2, 0xff])) == .map([.init(.unsigned(1), .unsigned(2))])) + #expect(try TrustCBOR.decode(Data([0x9f, 1, 2, 0xff])) == .array([.unsigned(1), .unsigned(2)])) + for invalid in [ + Data([0xbf, 1, 0xff]), Data([0xbf, 1, 2]), Data([0xff]), + Data([0xbf, 1, 2, 0x18, 1, 3, 0xff]), Data([0x9f]) + Data(repeating: 0, count: 129) + Data([0xff]), + ] { + #expect(throws: TrustError.malformedDocument) { try TrustCBOR.decode(invalid) } + } + } + + @Test func schemaRejectsWrongDigestAndOptionalFieldTypes() throws { + for (field, value) in [ + ("digest", TrustCBOR.text("SHA256")), ("public_key", .text("not bytes")), + ("module_id", .text("")), ("pcrs", .map([])), + ] { + let forged = try mutate(field, value: value) + #expect(throws: TrustError.malformedDocument) { + try AttestationVerifier().verify(base64: forged, nonce: Self.nonce, deviceTime: Self.signedAt) + } + } + } + + @Test func publicVerifierNeverAcceptsLocalMock() throws { + let payload = TrustCBOR.map([ + .init(.text("public_key"), .bytes(Data(repeating: 1, count: 32))), + .init(.text("user_data"), .bytes(Data("local-test".utf8))), + .init(.text("nonce"), .bytes(Self.nonce)), + ]) + let encoded = TrustCBOR.array([.bytes(Data()), .map([]), .bytes(payload.encode()), .bytes(Data())]).encode() + .base64EncodedString() + let parsed = try AttestationVerifier.parseLoopbackDocument(base64: encoded) + #expect(parsed.nonce == Self.nonce) + #expect(parsed.publicKey?.count == 32) + #expect(parsed.pcrs.isEmpty) + #expect(throws: TrustError.malformedDocument) { + try AttestationVerifier().verify(base64: encoded, nonce: Self.nonce, deviceTime: Self.signedAt) + } + } +} diff --git a/sdk/swift/Tests/MapleSDKTests/Trust/ClockDiagnosticTests.swift b/sdk/swift/Tests/MapleSDKTests/Trust/ClockDiagnosticTests.swift new file mode 100644 index 000000000..47c7b9f49 --- /dev/null +++ b/sdk/swift/Tests/MapleSDKTests/Trust/ClockDiagnosticTests.swift @@ -0,0 +1,69 @@ +import Foundation +import Testing +@testable import MapleSDK + +struct ClockDiagnosticTests { + private let signedAt = Date(timeIntervalSince1970: 1_800_000_000) + + @Test func validatedPublicDiagnosticPreservesDirectionAndThreshold() throws { + for seconds in [-60.0, -59.999, 0, 59.999, 60] { + let error = try #require( + AttestationClockSkewError( + validatingDeviceTime: signedAt.addingTimeInterval(seconds), enclaveTime: signedAt, + certificateIndex: 0, notBefore: signedAt.addingTimeInterval(-600), + notAfter: signedAt.addingTimeInterval(30))) + #expect(error.deviceClockLikelyWrong == (abs(seconds) >= 60)) + let description = try #require(error.errorDescription) + #expect(description.contains("date, time and time zone settings")) + if seconds <= -60 { #expect(description.contains("behind the secure enclave")) } + if seconds >= 60 { #expect(description.contains("ahead of the secure enclave")) } + } + } + + @Test func publicConstructionRejectsNonfiniteExtremeOrMalformedMetadata() { + for time in [Double.nan, .infinity, -.infinity, .greatestFiniteMagnitude, -62_135_596_801, 253_402_300_800] { + for index in 0..<4 { + var dates = Array(repeating: signedAt, count: 4) + dates[index] = Date(timeIntervalSince1970: time) + #expect( + AttestationClockSkewError( + validatingDeviceTime: dates[0], enclaveTime: dates[1], certificateIndex: 0, + notBefore: dates[2], notAfter: dates[3]) == nil) + } + } + #expect( + AttestationClockSkewError( + validatingDeviceTime: signedAt, enclaveTime: signedAt, certificateIndex: -1, + notBefore: signedAt, notAfter: signedAt) == nil) + #expect( + AttestationClockSkewError( + validatingDeviceTime: signedAt, enclaveTime: signedAt, certificateIndex: 0, + notBefore: signedAt, notAfter: signedAt.addingTimeInterval(-1)) == nil) + } + + @Test func widestSupportedDatesProduceBoundedDescriptionWithoutIntegerOverflow() throws { + let earliest = Date(timeIntervalSince1970: -62_135_596_800) + let latest = Date(timeIntervalSince1970: 253_402_300_799) + for pair in [(earliest, latest), (latest, earliest)] { + let error = try #require( + AttestationClockSkewError( + validatingDeviceTime: pair.0, enclaveTime: pair.1, certificateIndex: Int.max, + notBefore: earliest, notAfter: latest)) + let description = try #require(error.errorDescription) + #expect(description.utf8.count < 1_024) + #expect(description.contains("date, time and time zone settings")) + #expect(!description.contains("unrepresentable")) + } + } + + @Test func internalVerifierDiagnosticFormattingCannotTrapOnExtremeDeviceClock() throws { + for seconds in [Double.infinity, -.infinity, .greatestFiniteMagnitude] { + let error = AttestationClockSkewError( + deviceTime: Date(timeIntervalSince1970: seconds), enclaveTime: signedAt, + certificateIndex: 0, notBefore: signedAt, notAfter: signedAt) + let description = try #require(error.errorDescription) + #expect(description.contains("a very large amount of time")) + #expect(description.contains("an unrepresentable date")) + } + } +} diff --git a/sdk/swift/Tests/MapleSDKTests/Trust/Fixtures/nitro_attestation_document_2024-10-28.b64 b/sdk/swift/Tests/MapleSDKTests/Trust/Fixtures/nitro_attestation_document_2024-10-28.b64 new file mode 100644 index 000000000..0bb73b93b --- /dev/null +++ b/sdk/swift/Tests/MapleSDKTests/Trust/Fixtures/nitro_attestation_document_2024-10-28.b64 @@ -0,0 +1 @@ +hEShATgioFkRRqlpbW9kdWxlX2lkeCdpLTA2Yzc5YmY4MTcxMjcwMzBhLWVuYzAxOTJkM2Q0OTQ1ZTA0MzJmZGlnZXN0ZlNIQTM4NGl0aW1lc3RhbXAbAAABktR6lI5kcGNyc7AAWDDc4EGHJAjokKr9fAGFzfxPaALBBzgWx+MvbVtHhFEdpKrfBuPz+zwm07A2rC0HJtABWDA7Sn4bXxPFoQALPtMu+Jle4T6YdjKfm8cmULkYMp75z04uTR4eNzddqwula6CXTQMCWDAWLmtqrywWrIJO1Fay7WX++AWwdB/8hQfUzqq5mRdBPA4hGPo8mheSsdInqxndPScDWDDJplMJlIkNbR+fzbPBkfe82qWCZM8gTFmA9Cc3GHlH7sSjRJjMr9+tsDyoh1jKx8QEWDBhIohxs4hNf9+wjXWTTk+y93gY1W6r5JbwcBTFQBrcqDMLtNsnniqdG4N6igRWdycFWDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGWDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHWDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIWDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJWDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKWDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALWDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMWDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANWDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOWDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPWDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABrY2VydGlmaWNhdGVZAn8wggJ7MIICAaADAgECAhABktPUlF4EMgAAAABnH9xyMAoGCCqGSM49BAMDMIGOMQswCQYDVQQGEwJVUzETMBEGA1UECAwKV2FzaGluZ3RvbjEQMA4GA1UEBwwHU2VhdHRsZTEPMA0GA1UECgwGQW1hem9uMQwwCgYDVQQLDANBV1MxOTA3BgNVBAMMMGktMDZjNzliZjgxNzEyNzAzMGEudXMtZWFzdC0yLmF3cy5uaXRyby1lbmNsYXZlczAeFw0yNDEwMjgxODQ4MTVaFw0yNDEwMjgyMTQ4MThaMIGTMQswCQYDVQQGEwJVUzETMBEGA1UECAwKV2FzaGluZ3RvbjEQMA4GA1UEBwwHU2VhdHRsZTEPMA0GA1UECgwGQW1hem9uMQwwCgYDVQQLDANBV1MxPjA8BgNVBAMMNWktMDZjNzliZjgxNzEyNzAzMGEtZW5jMDE5MmQzZDQ5NDVlMDQzMi51cy1lYXN0LTIuYXdzMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEtkELnScpG3lJtzUIVksebKF6BN6fyHSYgiUXISazaZkenbe60cYUW3nXWJVBQnlmrDt7e7i2NrkLLQLRQqO+lpV7hFWnoC5wPOswDMzMFTBo9Mtjedt81EVRUrPgThUQox0wGzAMBgNVHRMBAf8EAjAAMAsGA1UdDwQEAwIGwDAKBggqhkjOPQQDAwNoADBlAjBnqTjl/bWue2ZYsMiMJdGgAVWqQBXjxT/xiaXlZb4HNXhCtca8zpz8ZWbKSdW2WTACMQD+o2PYymEc6GcV4OG67oZUJqd/YaL98TO9x40fULMY/b6NpbVK99+WCawUJ1Qyo3BoY2FidW5kbGWEWQIVMIICETCCAZagAwIBAgIRAPkxdWgbkK/hHUbMtOTn+FYwCgYIKoZIzj0EAwMwSTELMAkGA1UEBhMCVVMxDzANBgNVBAoMBkFtYXpvbjEMMAoGA1UECwwDQVdTMRswGQYDVQQDDBJhd3Mubml0cm8tZW5jbGF2ZXMwHhcNMTkxMDI4MTMyODA1WhcNNDkxMDI4MTQyODA1WjBJMQswCQYDVQQGEwJVUzEPMA0GA1UECgwGQW1hem9uMQwwCgYDVQQLDANBV1MxGzAZBgNVBAMMEmF3cy5uaXRyby1lbmNsYXZlczB2MBAGByqGSM49AgEGBSuBBAAiA2IABPwCVOumCMHzaHDimtqQvkY4MpJzbolL//Zy2YlES1BR5TSksfbb48C8WBoyt7F2Bw7eEtaaP+ohG2bnUs990d0JX28TcPQXCEPZ3BABIeTPYwEoCWZEh8l5YoQwTcU/9KNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUkCW1DdkFR+eWw5b6cp3PmanfS5YwDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMDA2kAMGYCMQCjfy+Rocm9Xue4YnwWmNJVA44fA0P5W2OpYow9OYCVRaEevL8uO1XYru5xtMPWrfMCMQCi85sWBbJwKKXdS6BptQFuZbT73o/gBh1qUxl/nNr12UO8Yfwr6wPLb+6NIwLz3/ZZAsIwggK+MIICRKADAgECAhBE1Vfji0EVOdX3ltVWm1jmMAoGCCqGSM49BAMDMEkxCzAJBgNVBAYTAlVTMQ8wDQYDVQQKDAZBbWF6b24xDDAKBgNVBAsMA0FXUzEbMBkGA1UEAwwSYXdzLm5pdHJvLWVuY2xhdmVzMB4XDTI0MTAyMzIwMjA1OVoXDTI0MTExMjIxMjA1OVowZDELMAkGA1UEBhMCVVMxDzANBgNVBAoMBkFtYXpvbjEMMAoGA1UECwwDQVdTMTYwNAYDVQQDDC0wZDA0M2M5ZDJkYzBiMGE1LnVzLWVhc3QtMi5hd3Mubml0cm8tZW5jbGF2ZXMwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT3MlqSAxOT8UyTEe16xJIWKTH77mU767Dn9M7nkGuPnkCZj8ZHmyUwuPdvOsBa1F83TYnziWUNM1NXTVmgXxWvmNF3XfqBQmmSoZlifKVNtI4RAHlhPSlH2O1iG86fOaOjgdUwgdIwEgYDVR0TAQH/BAgwBgEB/wIBAjAfBgNVHSMEGDAWgBSQJbUN2QVH55bDlvpync+Zqd9LljAdBgNVHQ4EFgQUrUeONPMVaVGG4zdpwwABMWrA12YwDgYDVR0PAQH/BAQDAgGGMGwGA1UdHwRlMGMwYaBfoF2GW2h0dHA6Ly9hd3Mtbml0cm8tZW5jbGF2ZXMtY3JsLnMzLmFtYXpvbmF3cy5jb20vY3JsL2FiNDk2MGNjLTdkNjMtNDJiZC05ZTlmLTU5MzM4Y2I2N2Y4NC5jcmwwCgYIKoZIzj0EAwMDaAAwZQIxAL5T8ALV5Qhgrv2kOpDnmGS7cFE2rS80UFKmqGF6tBwPj0QjRAcbHacfRUJmpN80zQIwNOsNuuwnrShZTjGfa7z7L6GulNHvLlW/7nh6aKyBkYdbLzx6qv6GjhKVJOW0rfUuWQMYMIIDFDCCApqgAwIBAgIQeLG5HWaGiQ9zqQsDkHnAqzAKBggqhkjOPQQDAzBkMQswCQYDVQQGEwJVUzEPMA0GA1UECgwGQW1hem9uMQwwCgYDVQQLDANBV1MxNjA0BgNVBAMMLTBkMDQzYzlkMmRjMGIwYTUudXMtZWFzdC0yLmF3cy5uaXRyby1lbmNsYXZlczAeFw0yNDEwMjgwMjIzMDhaFw0yNDExMDMwMjIzMDdaMIGJMTwwOgYDVQQDDDM5MDg1YTU5MGEwMTg1ZDhhLnpvbmFsLnVzLWVhc3QtMi5hd3Mubml0cm8tZW5jbGF2ZXMxDDAKBgNVBAsMA0FXUzEPMA0GA1UECgwGQW1hem9uMQswCQYDVQQGEwJVUzELMAkGA1UECAwCV0ExEDAOBgNVBAcMB1NlYXR0bGUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATm+nWH/CIoCP4spmBKG9Bcvc88GhMpOZaNjW5a61gfIt0Dx6FpfOLGQGzu2patGD9y/+K+iTko6PPUN5OIAndhb279q13dE+5Sx00ImT4HDzz3MghUCj8Wdwax/W0Lgk2jgeowgecwEgYDVR0TAQH/BAgwBgEB/wIBATAfBgNVHSMEGDAWgBStR4408xVpUYbjN2nDAAExasDXZjAdBgNVHQ4EFgQUOpIAydfrK09i5OmGw1mtzCpNrr4wDgYDVR0PAQH/BAQDAgGGMIGABgNVHR8EeTB3MHWgc6Bxhm9odHRwOi8vY3JsLXVzLWVhc3QtMi1hd3Mtbml0cm8tZW5jbGF2ZXMuczMudXMtZWFzdC0yLmFtYXpvbmF3cy5jb20vY3JsLzBkMmQyOThkLTMyYjItNDBmZi1hMjRiLTg0MTFjYWIxZTBhYi5jcmwwCgYIKoZIzj0EAwMDaAAwZQIwStxXvW9xsb/wpeVqP17VGgNIhS6/7kCmm/cznERBdoepc5+9BDOB2n1dUSzdhjjBAjEAjiz0Fn7nItNel46UKvBaBcj151yQtCqaRQazOdlX3uwF6VcjBRlk/VfEAq/Jo13dWQLEMIICwDCCAkWgAwIBAgIVAOg/q6n/YOYqSYUNmijoK1igw9IyMAoGCCqGSM49BAMDMIGJMTwwOgYDVQQDDDM5MDg1YTU5MGEwMTg1ZDhhLnpvbmFsLnVzLWVhc3QtMi5hd3Mubml0cm8tZW5jbGF2ZXMxDDAKBgNVBAsMA0FXUzEPMA0GA1UECgwGQW1hem9uMQswCQYDVQQGEwJVUzELMAkGA1UECAwCV0ExEDAOBgNVBAcMB1NlYXR0bGUwHhcNMjQxMDI4MTAzNTE1WhcNMjQxMDI5MTAzNTE1WjCBjjELMAkGA1UEBhMCVVMxEzARBgNVBAgMCldhc2hpbmd0b24xEDAOBgNVBAcMB1NlYXR0bGUxDzANBgNVBAoMBkFtYXpvbjEMMAoGA1UECwwDQVdTMTkwNwYDVQQDDDBpLTA2Yzc5YmY4MTcxMjcwMzBhLnVzLWVhc3QtMi5hd3Mubml0cm8tZW5jbGF2ZXMwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAS9oHs6mWDmOaFfUZdjMmjTlnokkn4gCOuqZmOUEdqgvu3nkHUpwqcVQBBCrg6BFlTmbMPQcphUwXZwnZLWL3MeYtx+xvZNVDjdhjFEGoCO94orRcUK+JW7oOWsHLuWnEOjZjBkMBIGA1UdEwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgIEMB0GA1UdDgQWBBSPwloegQ9tMrg6Fi5rm43yEVLHeTAfBgNVHSMEGDAWgBQ6kgDJ1+srT2Lk6YbDWa3MKk2uvjAKBggqhkjOPQQDAwNpADBmAjEApJy07sbKyxsy9T5/7EHTk51bJIXbuseGFQod2XVCtB7u4dRNrD4o6myqnAjDdkezAjEAlrRCVuDAwX+z1BY43GseEISF38IsZaNugOhbWgtg9kiM0JaGKcLt9ekJfc5vna89anB1YmxpY19rZXlYIPWKspWNcfffK89HXYgc2IC4xGfcBUqBIJ4EdODkruUPaXVzZXJfZGF0YfZlbm9uY2VYJGNjNmI5NWVmLWEwZDctNDc3ZC05MGYyLTM2Y2MwODhkMjQ0OVhgZE8M/gu4iXe3gWgVE/H7ULflqkB+THiPTH63WeLNktCIy2IfLe6C1rlhuAXXr6Tvry+BwhddRtvj22FiyW3s4LB6LL/8+wDH/pQfReYrqvuYTyRJUR+ZvUn16mdNIpMo diff --git a/sdk/swift/Tests/MapleSDKTests/Trust/LiveAttestationTests.swift b/sdk/swift/Tests/MapleSDKTests/Trust/LiveAttestationTests.swift new file mode 100644 index 000000000..480aa4bfe --- /dev/null +++ b/sdk/swift/Tests/MapleSDKTests/Trust/LiveAttestationTests.swift @@ -0,0 +1,79 @@ +import CryptoKit +import Foundation +import Security +import Testing +@testable import MapleSDK + +/// Opt-in anonymous development-enclave checks. Never records the document, +/// session identifiers, keys, or other captures. No user or inference call occurs. +struct LiveAttestationTests { + @Test(.enabled(if: ProcessInfo.processInfo.environment["MAPLE_RUN_LIVE_ATTESTATION"] == "1")) + func developmentNitroAndSignedHistory() async throws { + let url = URL(string: "https://enclave.secretgpt.ai/v2/session")! + var challenge = Data(count: 32) + let randomResult = challenge.withUnsafeMutableBytes { + SecRandomCopyBytes(kSecRandomDefault, $0.count, $0.baseAddress!) + } + try #require(randomResult == errSecSuccess) + let key = Curve25519.KeyAgreement.PrivateKey() + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "content-type") + request.setValue(challenge.base64EncodedString(), forHTTPHeaderField: "x-opensecret-routing-key") + request.httpBody = try JSONSerialization.data(withJSONObject: [ + "version": 2, + "challenge": challenge.base64EncodedString(), + "client_public_key": key.publicKey.rawRepresentation.base64EncodedString(), + ]) + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 20 + configuration.timeoutIntervalForResource = 20 + let session = URLSession(configuration: configuration, delegate: LiveNoRedirect(), delegateQueue: nil) + defer { session.invalidateAndCancel() } + let (bytes, response) = try await session.bytes(for: request) + let http = try #require(response as? HTTPURLResponse) + try #require(http.statusCode == 200) + try #require(http.url == url) + var data = Data() + for try await byte in bytes { + try #require(data.count < 128 * 1_024) + data.append(byte) + } + struct SessionResponse: Decodable { + let attestation_document: String; let version: Int; let expires_in_seconds: Int + } + let envelope = try JSONDecoder().decode(SessionResponse.self, from: data) + try #require(envelope.version == 2 && envelope.expires_in_seconds > 30) + let verifier = AttestationVerifier() + let now = Date() + let document = try verifier.verify(base64: envelope.attestation_document, nonce: challenge, deviceTime: now) + try #require(document.publicKey?.count == 32) + let clientBindingMatches = + document.userData == Data("opensecret/transport-v2/session/v1/client-public-key".utf8) + Data([0]) + + key.publicKey.rawRepresentation + #expect(clientBindingMatches) + let policy = try PCRTrustPolicy(environment: .development) + let hash = try await policy.requireTrustedPCR0(document.pcrs[0]) + // Fetch even when embedded roots match, so this checks the live signed + // history and pinned signature in addition to the ordinary fast path. + let history = try await SignedPCRHistory.fetch(PCRTrustPolicy.historyURL(.development)) + #expect(try SignedPCRHistory.verify(hash: hash, data: history)) + for interval in [-86_400.0 * 3, 86_400.0 * 3] { + #expect(throws: AttestationClockSkewError.self) { + try verifier.verify( + base64: envelope.attestation_document, nonce: challenge, + deviceTime: now.addingTimeInterval(interval)) + } + } + } +} + +private final class LiveNoRedirect: NSObject, URLSessionTaskDelegate, Sendable { + func urlSession( + _ session: URLSession, task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, + completionHandler: @escaping @Sendable (URLRequest?) -> Void + ) { + completionHandler(nil) + } +} diff --git a/sdk/swift/Tests/MapleSDKTests/Trust/PCRHistoryHTTPTests.swift b/sdk/swift/Tests/MapleSDKTests/Trust/PCRHistoryHTTPTests.swift new file mode 100644 index 000000000..22ec42310 --- /dev/null +++ b/sdk/swift/Tests/MapleSDKTests/Trust/PCRHistoryHTTPTests.swift @@ -0,0 +1,65 @@ +import Foundation +import Testing +@testable import MapleSDK + +struct PCRHistoryHTTPTests { + private func fetch(_ path: String) async throws -> Data { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [PCRHistoryProtocol.self] + return try await SignedPCRHistory.fetch( + URL(string: "https://history.test/\(path)")!, configuration: configuration) + } + + @Test func successfulHTTPBodyStillRequiresPinnedSignature() async throws { + let body = try await fetch("valid") + #expect(try SignedPCRHistory.verify(hash: PCRTrustTests.signedPCR0, data: body)) + let unsigned = try await fetch("unsigned") + #expect(try !SignedPCRHistory.verify(hash: PCRTrustTests.signedPCR0, data: unsigned)) + } + + @Test(arguments: ["status", "redirect", "content-length", "chunk-limit", "network-failure"]) + func untrustedHTTPFailuresAreSanitized(_ path: String) async { + await #expect(throws: TrustError.historyUnavailable) { try await fetch(path) } + } +} + +private final class PCRHistoryProtocol: URLProtocol, @unchecked Sendable { + override class func canInit(with request: URLRequest) -> Bool { request.url?.host == "history.test" } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + guard let url = request.url else { return } + let path = url.lastPathComponent + if path == "network-failure" { + client?.urlProtocol(self, didFailWithError: URLError(.timedOut)) + return + } + if path == "redirect" { + let response = HTTPURLResponse( + url: url, statusCode: 302, httpVersion: "HTTP/1.1", + headerFields: ["Location": "https://history.test/valid"])! + client?.urlProtocol( + self, wasRedirectedTo: URLRequest(url: URL(string: "https://history.test/valid")!), + redirectResponse: response) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocolDidFinishLoading(self) + return + } + let response = HTTPURLResponse( + url: url, statusCode: path == "status" ? 503 : 200, + httpVersion: "HTTP/1.1", headerFields: path == "content-length" ? ["Content-Length": "1048577"] : [:])! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + do { + let body = + path == "chunk-limit" + ? Data(repeating: 0, count: 1_048_577) + : try PCRTrustTests.history( + signature: path == "unsigned" + ? Data(repeating: 0, count: 96).base64EncodedString() : PCRTrustTests.signature) + client?.urlProtocol(self, didLoad: body) + client?.urlProtocolDidFinishLoading(self) + } catch { client?.urlProtocol(self, didFailWithError: error) } + } + + override func stopLoading() {} +} diff --git a/sdk/swift/Tests/MapleSDKTests/Trust/PCRTrustTests.swift b/sdk/swift/Tests/MapleSDKTests/Trust/PCRTrustTests.swift new file mode 100644 index 000000000..1f75ffde6 --- /dev/null +++ b/sdk/swift/Tests/MapleSDKTests/Trust/PCRTrustTests.swift @@ -0,0 +1,157 @@ +import Foundation +import Testing +@testable import MapleSDK + +struct PCRTrustTests { + @Test func embeddedRootsMatchCurrentAndRollbackApprovals() { + let production = PCRTrustPolicy.productionRoots + let development = PCRTrustPolicy.developmentRoots + for root in production + development { + #expect(root.utf8.count == 96) + #expect(root.utf8.allSatisfy { (48...57).contains($0) || (97...102).contains($0) }) + } + #expect(Set(production).isDisjoint(with: development)) + #expect( + PCRTrustPolicy.productionRoots == [ + "21d27bd19cdd02632485a6e8874753e42cf7f098f1335bb62a9c23e7f64da9100c02dba582da17ac68052fa6be16b7a9", + "643b3c293a4eac11ec14ab405d2405e46cb714263a12da6c62f485a0fa395cb3c3d5ef07b18d39a1f9715f25e7943b66", + ]) + #expect( + PCRTrustPolicy.developmentRoots == [ + "a95956c3fb9457e509be6d614ffefcddf98fe594f77bb6a56891fc1f5bdec41da2add13db22e0908363c8d0929c04def", + "e573a3ddc8a27f387e24cfb02a8fa594fe782674de6ee1fa9385126b9f8ec38f4a236e91c371bdd2b9444623c1c1cc34", + ]) + } + + static let signedPCR0 = + "3637534c33a8bafc5034d5763e441a481f161bbbe888e375ce14b016c7497dc4e550afe866bd8e65969b409d54766481" + static let signature = + "GZTXC0Xt0+yAaAatmMUd37pUJpF0nRAOj3Df9qxDOvDvRkiTF8UbGlzlL4kIOi/nd7dXAaEqYnY7OlpyngHBED2CSTpRRwV0xGo109epfqUKWWudrFaXpMsJ+GRKJLFO" + + static func bytes(_ hex: String) -> Data { + let chars = Array(hex) + return Data(stride(from: 0, to: chars.count, by: 2).map { UInt8(String(chars[$0...($0 + 1)]), radix: 16)! }) + } + + static func history(signature: String = signature, hash: String = signedPCR0) throws -> Data { + try JSONSerialization.data(withJSONObject: [ + [ + "PCR0": hash, "PCR1": String(repeating: "a", count: 96), + "PCR2": String(repeating: "b", count: 96), "timestamp": 1_743_710_235, "signature": signature, + "futureMetadata": ["ignored": true], + ] + ]) + } + + @Test func environmentsNeverFallBackToEachOther() async throws { + let production = try PCRTrustPolicy(remoteHistoryEnabled: false) + let development = try PCRTrustPolicy(environment: .development, remoteHistoryEnabled: false) + for (policy, roots, wrong) in [ + (production, PCRTrustPolicy.productionRoots, PCRTrustPolicy.developmentRoots), + (development, PCRTrustPolicy.developmentRoots, PCRTrustPolicy.productionRoots), + ] { + for root in roots { #expect(try await policy.requireTrustedPCR0(Self.bytes(root)) == root) } + for root in wrong { + await #expect(throws: TrustError.untrustedPCR0) { + try await policy.requireTrustedPCR0(Self.bytes(root)) + } + } + } + } + + @Test func signedHistoryAuthenticatesOnlyMatchingPCR0() throws { + #expect(try SignedPCRHistory.verify(hash: Self.signedPCR0, data: Self.history())) + #expect(try !SignedPCRHistory.verify(hash: String(repeating: "1", count: 96), data: Self.history())) + #expect( + try !SignedPCRHistory.verify( + hash: Self.signedPCR0, + data: Self.history(signature: Data(repeating: 0, count: 96).base64EncodedString()))) + #expect( + try !SignedPCRHistory.verify( + hash: String(repeating: "1", count: 96), data: Self.history(hash: String(repeating: "1", count: 96)))) + } + + @Test func remoteHistoryErrorsAndUnknownMeasurementsFailClosed() async throws { + let policy = try PCRTrustPolicy(environment: .development) + #expect(policy.remoteHistoryURL?.lastPathComponent == "pcrDevHistory.json") + let accepted = try await policy.requireTrustedPCR0(Self.bytes(Self.signedPCR0)) { url in + #expect(url.lastPathComponent == "pcrDevHistory.json") + return try Self.history() + } + #expect(accepted == Self.signedPCR0) + await #expect(throws: TrustError.untrustedPCR0) { + try await policy.requireTrustedPCR0(Self.bytes(Self.signedPCR0)) { _ in throw TrustError.historyUnavailable + } + } + await #expect(throws: TrustError.untrustedPCR0) { + try await policy.requireTrustedPCR0(Self.bytes(Self.signedPCR0)) { _ in Data("[]".utf8) } + } + await #expect(throws: CancellationError.self) { + try await policy.requireTrustedPCR0(Self.bytes(Self.signedPCR0)) { _ in throw CancellationError() } + } + } + + @Test func malformedPCR0CannotReachHistory() async throws { + let policy = try PCRTrustPolicy() + for invalid in [ + nil, Data(repeating: 1, count: 47), Data(repeating: 1, count: 49), Data(repeating: 0, count: 48), + ] { + await #expect(throws: TrustError.invalidPCR0) { + try await policy.requireTrustedPCR0(invalid) { _ in + Issue.record("Invalid PCR0 reached network"); return Data() + } + } + } + let local = PCRTrustPolicy.productionRoots[0] + _ = try await policy.requireTrustedPCR0(Self.bytes(local)) { _ in + Issue.record("Local root reached network"); return Data() + } + } + + @Test func historySchemaAndSizeAreBounded() throws { + for invalid in [ + Data(), Data("[]".utf8), Data("{}".utf8), Data(repeating: 32, count: 1_048_577), + try Self.history(signature: "bad"), try Self.history(hash: "x"), + try Self.history(hash: Self.signedPCR0.uppercased()), + ] { + #expect(throws: TrustError.historyUnavailable) { + try SignedPCRHistory.verify(hash: Self.signedPCR0, data: invalid) + } + } + let entry = try #require((JSONSerialization.jsonObject(with: Self.history()) as? [[String: Any]])?.first) + let tooMany = try JSONSerialization.data(withJSONObject: Array(repeating: entry, count: 2_049)) + #expect(throws: TrustError.historyUnavailable) { + try SignedPCRHistory.verify(hash: Self.signedPCR0, data: tooMany) + } + } + + @Test func configurationAndCacheBindingAreImmutableAndComplete() throws { + let normalized = try PCRTrustPolicy(additionalPCR0s: [ + " " + Self.signedPCR0.uppercased() + " ", Self.signedPCR0, + ]) + #expect(normalized.cacheIdentity == (try PCRTrustPolicy(additionalPCR0s: [Self.signedPCR0])).cacheIdentity) + #expect(normalized.cacheIdentity != (try PCRTrustPolicy()).cacheIdentity) + #expect( + normalized.cacheIdentity + != (try PCRTrustPolicy(environment: .development, additionalPCR0s: [Self.signedPCR0])).cacheIdentity) + #expect( + normalized.cacheIdentity + != (try PCRTrustPolicy(additionalPCR0s: [Self.signedPCR0], remoteHistoryEnabled: false)).cacheIdentity) + for value in ["", "hello", String(repeating: "0", count: 96)] { + #expect(throws: TrustError.invalidPolicy) { try PCRTrustPolicy(additionalPCR0s: [value]) } + } + for value in [ + "http://example.com/history", "https://user:password@example.com/history", + "https://example.com/history#fragment", + "http://localhost.evil.test/history", "http://127.0.0.1.evil.test/history", "file:///tmp/history", + ] { + #expect(throws: TrustError.invalidPolicy) { try PCRTrustPolicy(remoteHistoryURL: URL(string: value)!) } + } + for value in [ + "https://example.com/history", "http://127.0.0.1/history", "http://localhost/history", + "http://[::1]/history", + ] { + _ = try PCRTrustPolicy(remoteHistoryURL: URL(string: value)!) + } + } +} diff --git a/sdk/swift/VERSION b/sdk/swift/VERSION new file mode 100644 index 000000000..6e8bf73aa --- /dev/null +++ b/sdk/swift/VERSION @@ -0,0 +1 @@ +0.1.0