diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index 83cee89..6153ad1 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -17,11 +17,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Install, build, and upload - uses: withastro/action@v5 + uses: withastro/action@e84f40bd8d2caa9e768ec82ad30dd81f0b280853 # v6 with: path: website + node-version: 24 deploy: needs: build @@ -32,4 +33,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5 diff --git a/.github/workflows/ios-test.yml b/.github/workflows/ios-test.yml index 2bfd15a..1ac5339 100644 --- a/.github/workflows/ios-test.yml +++ b/.github/workflows/ios-test.yml @@ -62,12 +62,62 @@ jobs: -project KeyJawn.xcodeproj \ -scheme KeyJawn \ -destination "platform=iOS Simulator,name=${{ steps.sim.outputs.name }}" \ + -skip-testing:KeyJawnUITests/KeyboardIMETests \ -resultBundlePath "$GITHUB_WORKSPACE/TestResults.xcresult" + - name: Pick iPad simulator + id: ipad-sim + run: | + set -euo pipefail + LIST=$(xcrun simctl list devices available -j) + UDID=$(python3 -c ' + import json + import re + import sys + + devices_by_runtime = json.load(sys.stdin)["devices"] + + def runtime_version(identifier): + match = re.search(r"iOS-(\d+(?:-\d+)*)$", identifier) + if not match: + return () + return tuple(int(part) for part in match.group(1).split("-")) + + runtimes = sorted( + devices_by_runtime.items(), + key=lambda item: runtime_version(item[0]), + reverse=True, + ) + for _, devices in runtimes: + for device in devices: + if device.get("isAvailable") and device["name"].startswith("iPad"): + print(device["udid"]) + raise SystemExit + ' <<< "$LIST") + if [ -z "$UDID" ]; then + echo "No available iPad simulator:" + printf '%s\n' "$LIST" + exit 1 + fi + echo "udid=$UDID" >> "$GITHUB_OUTPUT" + + - name: Test iPad system keyboard + working-directory: ios + run: | + set -euo pipefail + xcodebuild test \ + -project KeyJawn.xcodeproj \ + -scheme KeyJawn \ + -destination "platform=iOS Simulator,id=${{ steps.ipad-sim.outputs.udid }}" \ + -only-testing:KeyJawnUITests/KeyboardIMETests \ + -resultBundlePath "$GITHUB_WORKSPACE/IPadKeyboardResults.xcresult" + - name: Upload xcresult if: failure() uses: actions/upload-artifact@v4 with: name: ios-test-xcresult - path: TestResults.xcresult + path: | + TestResults.xcresult + IPadKeyboardResults.xcresult if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index a9fdcd1..f691f26 100644 --- a/.gitignore +++ b/.gitignore @@ -26,11 +26,17 @@ play-service-account.json __pycache__/ *.pyc store/keyjawn-store.db +store/keyjawn-store.db-* store/venv/ store/gunicorn.ctl +# Worker packaging output +worker/build/ +worker/*.egg-info/ + # iOS / Xcode xcuserdata/ +ios/scratch/ ios/KeyJawnKit/.build/ ios/web-poc/node_modules/ ios/web-poc/hosts.json diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..428b254 --- /dev/null +++ b/.swift-format @@ -0,0 +1,7 @@ +{ + "indentation": { + "spaces": 4 + }, + "lineLength": 120, + "version": 1 +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d964803 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,51 @@ +# Changelog + +This file records user-visible changes and release state. A source change is not +a distributed release. + +## Unreleased + +### iOS + +- Clarified that terminal commands execute on a user-configured remote SSH + server, not on the iPhone or iPad. +- Removed the session-free Preview terminal and unused review-facing metadata. +- Clarified that copied-image upload writes one prepared image to a configured + remote directory. That upload feature does not browse local files or list, + read, or download remote files. +- Limited copied-image upload to SSH key-authenticated hosts. Password + authentication remains available for terminal connections. +- Clarified that the keyboard extension inserts text or control sequences and + that the receiving app decides how to interpret them. +- Fixed non-ASCII terminal input, which previously sent a single lossy byte. +- Corrected the keyboard extension metadata to declare its standard ASCII input. +- Added tests for the remote-execution, local-file, keyboard, and metadata + boundaries. +- Added reviewer-safe iPhone and iPad App Store screenshot sets from the current + build. + +### Documentation and worker + +- Added one App Store metadata and reviewer-response draft with credential-free + placeholders. +- Added platform labels and iOS custom-keyboard limits to active public copy. +- Added Android, TestFlight, and App Store release-note templates. +- Updated social-worker prompts so they do not claim unsupported iOS behavior or + an App Store launch. + +### Distribution state + +- KeyJawn Lite 1.3.0 has active internal and closed Google Play test tracks. +- Google Play production is inactive and Google Play app signing is active. + The checked-in Android source and a local verification bundle are version + 1.4.0, version code 10, and target API 36. The bundle used a disposable key + and cannot be uploaded. +- Apple rejected review build 2 under App Store Review guideline 2.5.2. +- Source build 9 exists in the repository. +- A fresh signed build 9 archive was created and verified from the corrected + source on August 31, 2026. The exact archive was uploaded, and Apple processed + build 9 as valid. Build 9 is selected for version 1.0, manual release is + enabled, and the submission is waiting for review. The reviewer response and + test steps were included in the review notes. The app is not approved and not + publicly released. +- This remediation made no Android distribution change. diff --git a/CLAUDE.md b/CLAUDE.md index 9cdfc53..df86cef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,10 +4,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this is -KeyJawn is a custom keyboard for using LLM CLI agents from a phone. It provides terminal keys (Esc, Tab, Ctrl, arrows), voice-to-text, slash command shortcuts, and SCP image upload — all in a dedicated row above a standard QWERTY layout. +KeyJawn provides terminal-oriented mobile input for remote CLI work. Android is +a custom keyboard with voice input and optional SCP upload. iOS is a remote SSH +terminal with a companion keyboard and copied-image SFTP upload. -- **Android**: `InputMethodService`-based keyboard extension. Two flavors: lite (free, Google Play) and full ($4, Stripe/website). -- **iOS**: Standalone SwiftUI app with a built-in SSH terminal (SwiftTerm + SwiftNIO SSH via Citadel) and a companion `UIInputViewController` keyboard extension. Currently in TestFlight beta; App Store launch pending. +- **Android**: `InputMethodService`-based keyboard extension. Two flavors: lite + (free APK and limited Google Play testing) and full ($4, Stripe/website). +- **iOS**: Standalone SwiftUI app with a remote SSH terminal (SwiftTerm + SwiftNIO SSH via Citadel) and a companion `UIInputViewController` keyboard extension. Apple rejected review build 2 under guideline 2.5.2. A fresh signed build 9 archive was created and verified from the corrected source on August 31, 2026. The exact archive was uploaded, and Apple processed build 9 as valid. Build 9 is selected for version 1.0, manual release is enabled, and the submission is waiting for review. The app is not approved and not publicly released. ## Build commands @@ -114,8 +117,21 @@ xcodebuild -project KeyJawn.xcodeproj -scheme KeyJawn \ xcodebuild test -project KeyJawn.xcodeproj -scheme KeyJawn \ -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -# Archive + export for TestFlight/App Store -bash ios/scripts/build.sh +# App and installed system-keyboard UI tests +# Keep the Simulator software keyboard visible. Use Command-K if iPhone hides it. +xcodebuild test -project KeyJawn.xcodeproj -scheme KeyJawn \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + -only-testing:KeyJawnUITests + +# Real SSH/SFTP checks are opt-in. See docs/ios-app-review.md for the required +# KEYJAWN_LIVE_SSH_* variables and cleanup rules. +xcodebuild test -project KeyJawn.xcodeproj -scheme KeyJawn \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + -only-testing:KeyJawnKitTests/SSHLiveIntegrationTests + +# Static analysis +xcodebuild analyze -project KeyJawn.xcodeproj -scheme KeyJawn \ + -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO ``` **Versioning:** `CURRENT_PROJECT_VERSION` and `MARKETING_VERSION` in `ios/project.yml` @@ -129,111 +145,56 @@ duplicate upload. **Architecture:** - `KeyJawnKit/` — shared Swift package used by both the main app and the keyboard extension. Contains keyboard models (`KeyboardLayout`, `HostConfig`, `SlashCommand`, `CtrlState`) and UIKit views (`QwertyKeyboardView`, `ExtraRowView`, `SlashCommandPanel`). - `KeyJawn/` — main SwiftUI app. Host management (`HostListView`, `HostEditView`, `HostStore`), SSH terminal (`SSHSession` via Citadel/SwiftNIO SSH, `TerminalViewController` via SwiftTerm), settings (`SettingsView`, `SSHKeysView`), and `SSHKeyStore` (Keychain-backed Ed25519 identity key). -- `KeyJawnKeyboard/` — `UIInputViewController` keyboard extension. Uses `QwertyKeyboardView` + `ExtraRowView` from KeyJawnKit. Reads host configs through `group.com.keyjawn` (`AppGroupHostStore`) and the SSH identity through the shared Keychain access group (`SharedSSHKeyStore`). SCP image upload via `CitadelSCPUploader` (Citadel/SwiftNIO SFTP). +- `KeyJawnKeyboard/` — `UIInputViewController` keyboard extension. Uses `QwertyKeyboardView` + `ExtraRowView` from KeyJawnKit. Reads host configs through `group.com.keyjawn` (`AppGroupHostStore`) and the SSH identity through the shared Keychain access group (`SharedSSHKeyStore`). Copied-image upload accepts only key-authenticated hosts and uses `CitadelSCPUploader` to write to a configured remote path through SFTP. Password authentication remains available for terminal connections. **Key decisions:** - One Ed25519 identity key for the whole app (not per-host). Public key is shown in Settings → SSH keys for the user to copy to `authorized_keys`. Private key is stored once in Keychain under `com.keyjawn / ssh-identity-ed25519`, accessible `WhenUnlockedThisDeviceOnly`, using the shared access group `$(AppIdentifierPrefix)com.keyjawn.shared` so both the app and keyboard extension can read it. - Upgrades migrate the old app-specific Keychain item into the shared group and delete the former App Group file or `UserDefaults` mirror only after a byte-for-byte read-back succeeds. The app-specific access group remains first in both entitlements during this migration release and is passed explicitly to every legacy query or deletion; an unscoped Keychain deletion can also match the shared item. -- Keyboard preferences (`KeyboardPrefs`) live in the `group.com.keyjawn` suite, not `UserDefaults.standard` — the app and the extension have separate standard suites, so anything written there never reaches the keyboard. The extension can only read the shared suite with Full Access; without it the keyboard falls back to defaults. +- Keyboard preferences (`KeyboardPrefs`) live in the `group.com.keyjawn` suite, not `UserDefaults.standard` — the app and the extension have separate standard suites, so anything written there never reaches the keyboard. KeyJawn exposes its optional shared settings and clipboard features only with Full Access; basic input falls back to defaults without it. - Host key pinning via `NIOSSHPublicKey(openSSHPublicKey:)`. The main app owns a short-lived NIO probe that captures and displays an unpinned server's fingerprint, closes before authentication, then reconnects through Citadel only after the accepted key is saved. The keyboard extension refuses unpinned hosts. - Keyboard extension cannot use `present()`. Overlays (slash command panel) are added as `UIView` children of the extension root view. - Debug build uses automatic signing; Release build uses manual signing with App Store provisioning profiles (`KeyJawn AppStore`, `KeyJawn Keyboard AppStore`). - App Group `group.com.keyjawn` is registered in Apple Developer portal and enabled on both App IDs (`com.keyjawn` and `com.keyjawn.keyboard`). Provisioning profiles regenerated 2026-02-20. -**App Store Connect:** -- App ID: `com.keyjawn` / keyboard extension: `com.keyjawn.keyboard` -- App Store Connect app numeric ID: `6759345867` -- App Store version 1.0 ID: `071542f6-0cdb-43a5-b07c-7b74688e937b` -- Build v2 ID: `7e8af1a4-eac8-4b57-8ae0-8a3bd2655c1f` — current build in review -- Screenshots uploaded: `APP_IPHONE_67` (1290×2796) + `APP_IPAD_PRO_3GEN_129` (2048×2732) for en-US localization -- Provisioning profiles managed via Apple Developer API (`ios/scripts/asc.py`) -- App Store Connect API credentials in `pass` at `claude/services/appstore-connect-{issuer-id,key-id,api-key}` - -**App Store Connect API notes:** -- `appStoreVersionSubmissions` is deprecated — use `reviewSubmissions` + `reviewSubmissionItems` instead -- Submit flow: `POST /v1/reviewSubmissions` → `POST /v1/reviewSubmissionItems` → `PATCH /v1/reviewSubmissions/{id}` with `submitted: true` -- Pricing set via `POST /v1/appPriceSchedules` with `baseTerritory: USA` and inline `appPrices` using `${local-id}` format (must use file-based script, not heredoc — shell strips `${}`) -- App Privacy (data usage) labels must be published via web UI — no API endpoint exists for this -- `usesNonExemptEncryption: false` must be set on each build via `PATCH /v1/builds/{id}` (SSH uses IETF-standard algorithms, exempt from EAR) -- Screenshot upload flow: create set → reserve slot (`POST /v1/appScreenshots`) → PUT bytes to `uploadOperations[].url` → commit with MD5 checksum - -**Submission status (2026-02-24):** -- App Store review: appeal in progress, state unknown — Apple replied 2026-02-24 "we will continue the review and will notify if there are further issues" -- Review submission ID: `83b2805c-650c-4bf6-91ff-0c6339f36324` -- TestFlight external group: "KeyJawn Beta (external)" (ID `360a2954-431c-4d74-aa68-faaf86baa926`) -- Public TestFlight link: `https://testflight.apple.com/join/8vMqguKK` (limit 50 testers) -- Internal group: "KeyJawn Beta" (ID `55817758-6243-4d2b-b0f3-ffc95221cbdb`) - -- **Submission `83b2805c` (v1.0) rejected 2026-02-20** for Guideline 3.2.2 — reviewer interpreted the slash command panel as a third-party app collection due to tool-branded category names (`claudeCode`, `aider`, `codex`) in the data model and a branded screenshot. - - Reply sent to Apple explaining `textDocumentProxy.insertText()` behavior (text autocomplete, not a storefront) - - `SlashCommand.Category` enum renamed: `claudeCode`→`session`, `aider`→`context`, `codex`→`files` - - Aider command set replaced with Gemini CLI commands (commits `56c11fd`, `b361c92`, `c495696`) - - `ios-claude-code.png` renamed to `ios-slash-commands.png` (commit `325f1c5`) — **PNG content still needs replacement** with an unbranded keyboard screenshot from Mac/Simulator - - App Store description audited — no third-party tool names found, no changes needed -- **2026-02-24:** Apple replied "we will continue the review" — appeal accepted, re-review underway. Waiting on outcome before deciding whether to submit a new build. - -### Mac checklist (do this when you sit down at the MacBook) - -**Step 1: Pull latest** -```bash -cd /path/to/keyjawn -git pull origin main -``` - -**Step 2: Take a new screenshot** -1. Open Xcode, run the KeyJawn app on the iPhone 17 Pro simulator -2. Open any text field (e.g. Notes), switch to KeyJawn Keyboard, tap the `/` key on the symbols layer to open the slash command panel -3. Screenshot: panel open over the keyboard showing shortcuts (`/compact`, `/clear`, etc.) — **no Claude Code or other third-party tool branding visible in the terminal area** -4. Save it as the replacement file: -```bash -cp ~/Desktop/screenshot.png website/public/screenshots/ios-screenshots/ios-slash-commands.png -``` -5. Commit: -```bash -git add website/public/screenshots/ios-screenshots/ios-slash-commands.png -git commit -m "replace slash commands screenshot with unbranded keyboard UI" -``` - -**Step 2b: Run the tests** - -```bash -cd ios && xcodebuild test -project KeyJawn.xcodeproj -scheme KeyJawn \ - -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -``` -This is the first thing to run on the Mac — none of the iOS code has ever been -compiled in a web session, so the suite is also the build check. - -**Step 3: Bump the build number** - -Open `ios/project.yml` and increment `CURRENT_PROJECT_VERSION` **on both targets** -(`KeyJawn` and `KeyJawnKeyboard`) — they must match or App Store Connect rejects the -upload. The Info.plists interpolate the setting, so this is the only edit needed. -```bash -git add ios/project.yml -git commit -m "bump iOS build number for 3.2.2 resubmission" -``` - -**Step 4: Build and upload to TestFlight** -```bash -cd ios -bash scripts/build.sh -``` -Expected: archives, exports IPA, uploads to TestFlight. Build appears in App Store Connect → TestFlight within ~15 min with status "Processing". - -**Step 5: Push** -```bash -git push origin main -``` - -**Step 6: Decide on resubmission** - -Check App Store Connect → App Review for Apple's response to the appeal (submission `83b2805c`): -- **Appeal approved** → submit the new build as an update when ready -- **Appeal denied or no response after 7 days** → cancel the current submission, then submit the new build with this note in App Review Information → Notes: - -> The slash command panel is a text shortcut picker. Tapping any item inserts a plain text string (e.g. "/compact") into the active text field. No third-party apps are embedded, linked, or sold. Category labels in the code have been renamed from tool names to functional terms (Session, Context, Files) to remove any ambiguity. - -**Code style:** Swift 6 strict concurrency. Use `@MainActor` for UI classes. For Citadel types that lack `Sendable` conformance, add `@unchecked @retroactive Sendable` extensions (see `SSHSession.swift`). No emojis in source or UI. +**App Store Connect status:** + +- App ID: `com.keyjawn`; keyboard extension: `com.keyjawn.keyboard`. +- App Store Connect app ID: `6759345867`. +- Review submission ID: `83b2805c-650c-4bf6-91ff-0c6339f36324`. +- Apple sent a guideline 2.5.2 rejection on June 12, 2026. The message identifies + build 2 and an iPad Air 11-inch (M3). The checked-in source is build 9. +- A fresh signed build 9 archive was created and verified from the corrected + source on August 31, 2026. The exact archive was uploaded, and Apple processed + build 9 as valid. Build 9 is selected for version 1.0, manual release is + enabled, and review submission `83b2805c-650c-4bf6-91ff-0c6339f36324` is + waiting for review. The reviewer response and test steps were included in the + review notes. The app is not approved and not publicly released. +- The remote-execution, sandbox, and test evidence are in + `docs/ios-app-review.md`. The metadata template, submitted review-note basis, + and response copy are in `docs/app-store-v1.0-metadata.md`. +- `CHANGELOG.md` records source changes separately from distribution state. + +**Release gates:** + +- Do not change the build number until the complete verification pass succeeds and + the user approves an unused number. Change both target values together. +- Do not run `ios/scripts/build.sh`, archive, upload, send a reviewer reply, change + App Store Connect metadata, cancel a submission, or resubmit without separate + user approval at action time. +- Website publication is a separate external action. A push to `main` that changes + `website/**` triggers `.github/workflows/deploy-site.yml` and publishes the site. + Get explicit approval before that push. +- Build 9 has `usesNonExemptEncryption: false` after final archive inspection, + France exclusion, Apple documentation review, and App Store Connect read-back. + No French declaration is required. Keep the separate US BIS classification or + reporting duty open until qualified guidance resolves it. +- App Privacy answers are managed in App Store Connect. Read-only inspection is + allowed for verification. Do not select Publish without explicit approval. +- Do not store review credentials in the repository or logs. + +**Code style:** Swift 6 strict concurrency. Keep UI state on `@MainActor`. Keep NIO +channel handlers on their owning event loop. Do not add unchecked `Sendable` +conformance only to silence a compiler warning. No emojis in source or UI. ## Store service @@ -348,16 +309,27 @@ Shortcut prompts stored at `~/.claude/commands/keyjawn-*.md` on houseofjawn. The - **CI publishing:** Gradle Play Publisher (GPP) 3.13.0 in `.github/workflows/build.yml` - `publish-play-store` job runs on version tags after the `release` job - Needs `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` GitHub secret (service account key JSON) - - `PLAY_TRACK` defaults to `internal` — change to `production` once verified + - `PLAY_TRACK` defaults to `internal`; a separate approved promotion updates + the active closed track - Only publishes `publishLiteReleaseBundle` — full flavor is never uploaded to Play - **Store listing metadata:** `app/src/lite/play/listings/en-US/` (managed by GPP) - **Asset generation:** `scripts/generate-store-assets.py` (Selenium + Chromium snap → icon, feature graphic, screenshots) -- **Setup status (completed 2026-02-17):** +- **Verified state on 2026-08-27:** - App created in Play Console - - First AAB uploaded to internal testing (v1.3.0) + - KeyJawn Lite 1.3.0 is active on internal testing and one closed testing track + - Open testing and production are inactive + - Google Play app signing is active + - 7 of the required 12 closed-test users are opted in; production access also + requires at least 14 days with 12 opted-in testers - Store listing, content rating, data safety, and all other forms complete - - 7 testers configured on internal track - - CI auto-publish ready (`publish-play-store` job) -- change track to `production` when ready + - Source version 1.4.0, version code 10, targets API 36. A local + disposable-key bundle verified that manifest but cannot be uploaded. The + Google Play build still reports an API-level action due August 31, 2026. + - Android unit suites pass 425 full-flavor and 421 lite-flavor tests. Both + lint variants complete without errors. Existing lint warnings and stale + baseline entries need a separate Android cleanup pass. + - A version tag uploads to internal testing. Promotion to closed testing or + production is a separate external action and needs explicit approval. ## Release checklist — SOCIAL.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a2d0493..c8978cf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,44 +1,65 @@ # Contributing to KeyJawn -Thanks for your interest in contributing. KeyJawn is a custom Android keyboard for LLM CLI users, and we welcome contributions that make it more useful for that workflow. +Thanks for your interest in contributing. KeyJawn has an Android keyboard and an +iOS remote SSH terminal with a companion keyboard. ## Getting started -1. Fork the repo -2. Clone your fork -3. Create a branch: `git checkout -b feature/your-feature` -4. Make your changes -5. Push and open a pull request +1. Fork the repository. +2. Clone your fork. +3. Create a branch: `git checkout -b feature/your-feature`. +4. Make and test your changes. +5. Push the branch and open a pull request. ## Development setup -KeyJawn is a standard Android Kotlin project: +### Android -- **Android Studio** (latest stable) or command-line Android SDK -- **JDK 17+** -- The Gradle wrapper provisions its pinned **JDK 21** daemon toolchain -- **Min SDK 26** (Android 8.0) -- **Target SDK 36** +- Android Studio or the command-line Android SDK. +- JDK 17 or later. The Gradle wrapper provisions its pinned JDK 21 daemon + toolchain. +- Android SDK 36. The minimum supported version is Android 8.0, API 26. -### Building +Build both Android flavors: ```bash -./gradlew :app:assembleDebug +./gradlew assembleFullDebug +./gradlew assembleLiteDebug ``` -### Running tests +Run Android tests: ```bash -./gradlew :app:testDebugUnitTest +./gradlew testFullDebugUnitTest +./gradlew testLiteDebugUnitTest ``` -### Installing on a device +Install one Android flavor: ```bash -adb install app/build/outputs/apk/debug/app-debug.apk +adb install app/build/outputs/apk/full/debug/app-full-debug.apk ``` -Or download the debug APK from the GitHub Actions build artifacts. +### iOS + +- Xcode 26 or later. +- Swift 6 and an iOS 17 or later deployment target. +- XcodeGen. Edit `ios/project.yml`, then regenerate the project. + +Build and test from `ios/`: + +```bash +xcodegen generate +xcodebuild -project KeyJawn.xcodeproj -scheme KeyJawn \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build +xcodebuild test -project KeyJawn.xcodeproj -scheme KeyJawn \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' +``` + +The iOS terminal sends input to a user-configured remote SSH server. Do not add a +local shell, command interpreter, device-file browser, runtime loader, or other +behavior that conflicts with the boundary in +[`docs/ios-app-review.md`](docs/ios-app-review.md). ## What to contribute @@ -48,11 +69,10 @@ Look for issues labeled `good first issue`. These are scoped, well-defined tasks ### Feature ideas -- New terminal keys or key combinations -- Long-press behaviors for existing keys -- Visual themes -- SSH connection improvements -- Support for other LLM CLI tools +- Android keyboard keys, touch behavior, voice input, or themes. +- iOS remote SSH connection and terminal improvements. +- iOS companion-keyboard improvements that use supported text-input APIs. +- Tests, accessibility work, and clear documentation. ### Before you start @@ -60,23 +80,32 @@ For anything beyond a small bug fix, open an issue first to discuss the approach ## Code style -- Kotlin with standard Android conventions -- No emojis in source code, logs, or UI text -- Sentence case for all UI strings (not Title Case) -- Keep it simple — avoid unnecessary abstractions -- Write tests for new functionality +- Use standard Kotlin conventions for Android and Swift 6 strict concurrency for + iOS. +- Use sentence case for UI text. +- Do not use emojis in source code, logs, or UI text. +- Prefer a small, direct change over a new abstraction. +- Add tests for new behavior and regression tests for bug fixes. +- Label platform-specific features as Android-only or iOS-only in public copy. ## Pull request process -1. Write tests for your changes -2. Make sure all tests pass: `./gradlew :app:testDebugUnitTest` -3. Keep PRs focused — one feature or fix per PR -4. Write a clear description of what changed and why +1. Write tests for the change. +2. Run the applicable Android or iOS build, test, lint, and analysis checks. +3. Update the README, changelog, and platform documentation when behavior + changes. +4. Keep the pull request focused on one feature or fix. +5. Explain why the change is needed and include the commands and results used to + verify it. + +A pull request must not archive or upload an iOS build, change App Store Connect +metadata, send an App Review response, cancel a submission, or resubmit the app. +Those actions require separate approval at action time. ## Reporting bugs Open an issue with: -- Device model and Android version +- Device model and operating-system version - App version - Steps to reproduce - What you expected vs what happened diff --git a/README.md b/README.md index 597fa68..789b726 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# KeyJawn: Custom mobile keyboard for LLM CLI usage +# KeyJawn: custom mobile keyboard for LLM CLI use ![KeyJawn branding graphic](https://i.imgur.com/c6z2Gl0.jpeg) @@ -11,7 +11,8 @@ [![GitHub release](https://img.shields.io/github/v/release/jamditis/keyjawn?color=6cf2a8&style=flat)](https://github.com/jamditis/keyjawn/releases) [![Site](https://img.shields.io/badge/site-keyjawn.amditis.tech-6cf2a8?style=flat)](https://keyjawn.amditis.tech) -A custom keyboard for using LLM CLI agents (Claude Code, OpenClaw, etc.) from your phone. Android keyboard extension + iOS terminal app. +A mobile keyboard for using LLM CLI agents from a phone. KeyJawn includes an +Android keyboard and an iOS remote SSH terminal with a companion keyboard. ## Install @@ -19,26 +20,64 @@ A custom keyboard for using LLM CLI agents (Claude Code, OpenClaw, etc.) from yo **Free version**: Download the lite APK from [GitHub releases](https://github.com/jamditis/keyjawn/releases). Includes voice input, clipboard history, slash commands, swipe gestures, and per-app autocorrect. +KeyJawn Lite also has active internal and closed Google Play test tracks. +Google Play production is not active. + **Full version ($4)**: Buy on [the website](https://keyjawn.amditis.tech) via Stripe. After purchase, you'll get an email with a private download link (expires in 7 days). New versions are emailed automatically. After installing: + 1. Go to **Settings > System > Languages & input > On-screen keyboard** 2. Enable **KeyJawn** 3. Set KeyJawn as your default keyboard -### iOS (beta) - -The iOS version is a standalone terminal app, not just a keyboard extension. It has a built-in SSH client (SwiftTerm + SwiftNIO SSH) so you can connect directly to your servers. A companion keyboard extension adds Esc, Tab, Ctrl, arrows, and slash commands to any app. - -Currently in TestFlight beta. App Store launch coming soon. +### iOS beta + +The iOS app connects to remote SSH servers. Commands run on the configured server, +and the app displays the returned output. It does not run a local shell or browse +files on the iPhone or iPad. Copied-image upload writes the prepared image to a +configured path on a remote host that uses SSH key authentication. The upload +reads only the image that the user copied to the system pasteboard. It does not +open Photos or Files. + +Normal hosts use plain SSH. A host that exposes SSH through a TLS-terminated TCP +tunnel can enable **TLS tunnel** in its host settings. The app still verifies the +SSH host key inside that TLS connection. Use a DNS hostname that matches the TLS +certificate, or an IP address that matches an IP-address certificate entry. +Self-signed certificates are not supported for TLS tunnels. Review the SSH +fingerprint on first connection because plain `ssh-keyscan` cannot cross a TLS +tunnel by itself. + +A companion keyboard extension provides QWERTY input, terminal-oriented controls, +and text shortcuts in apps and text fields that support third-party keyboards. +The extension inserts text or control sequences into the focused field. The +receiving app decides how to interpret them. The built-in SSH terminal sends +terminal bytes directly. Basic typing works without Allow Full Access. Full +Access is optional. KeyJawn uses it only for copied-image upload and shared +keyboard settings, user-created shortcuts, or clipboard history. + +iOS uses the system keyboard for passcodes and secure text fields, and for +fields that use the `phonePad` or `namePhonePad` keyboard type. Apps can also +block third-party keyboards. + +The public TestFlight invitation is not accepting new testers as of August 28, +2026. Apple rejected App Store review build 2 under guideline 2.5.2. A fresh +signed build 9 archive was created and verified from the corrected source on +August 31, 2026. The exact archive was uploaded, and Apple processed build 9 as +valid. Build 9 is selected for version 1.0, manual release is enabled, and the +submission is waiting for review. The app is not approved and not publicly +released. To use the keyboard extension in other apps after installing: + 1. Go to **Settings > General > Keyboard > Keyboards > Add new keyboard** 2. Select **KeyJawn Keyboard** -## Features +## Android features + +The features in this section are Android-only unless the text says otherwise. -### Free (lite APK) +### Android free version - QWERTY keyboard with three layers (lowercase, uppercase, symbols) - Terminal key row: Esc, Tab, Ctrl (three-state toggle), arrow keys @@ -53,7 +92,7 @@ To use the keyboard extension in other apps after installing: - Color-coded extra row keys for quick identification - Shift / caps lock with visual state indicator -### Full version ($4 one-time purchase) +### Android full version ($4 one-time purchase) Everything in free, plus: - SCP image upload to remote SSH servers @@ -64,14 +103,15 @@ Everything in free, plus: - Clipboard pinning (persistent across sessions) - Tooltip toggle -## Who it's for +## Who it is for -Anyone who SSHs into a server from their phone to use a CLI-based AI assistant. Built for and tested with: +Anyone who uses a remote server from a phone to work with a CLI-based AI +assistant. These integration examples are Android-only: - Claude Code via Cockpit web terminal - Direct SSH apps (Termux, JuiceSSH, ConnectBot) - Any LLM CLI that uses slash commands (OpenClaw, etc.) -## Versions +## Android versions | | Full | Lite | |---|---|---| @@ -95,14 +135,21 @@ Anyone who SSHs into a server from their phone to use a CLI-based AI assistant. # Release builds (requires signing config) ./gradlew assembleFullRelease bundleFullRelease +./gradlew bundleLiteRelease # Tests ./gradlew testFullDebugUnitTest ./gradlew testLiteDebugUnitTest + +# Static checks +./gradlew lintFullDebug lintLiteDebug ``` Requires JDK 17+ and Android SDK 36. The Gradle wrapper provisions its pinned JDK 21 daemon toolchain. +The current App Store screenshot inventory and validation rules are in +[`ios/AppStore/Screenshots/README.md`](ios/AppStore/Screenshots/README.md). + ### iOS ```bash @@ -116,17 +163,23 @@ xcodebuild -project KeyJawn.xcodeproj -scheme KeyJawn \ -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ -configuration Debug build -# Archive for App Store (requires Apple Developer account + provisioning profiles) -bash scripts/build.sh +# Run unit tests +xcodebuild test -project KeyJawn.xcodeproj -scheme KeyJawn \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' ``` -Requires Xcode 26+, Swift 6, iOS 17+ deployment target. Uses XcodeGen to manage the project file — edit `project.yml` instead of the `.xcodeproj` directly. +Requires Xcode 26+, Swift 6, and an iOS 17+ deployment target. XcodeGen manages +the project file. Edit `project.yml`, not the `.xcodeproj` directly. Archive, +upload, metadata, reviewer reply, and resubmission actions require separate user +approval. ## Links - [Website](https://keyjawn.amditis.tech) - [Privacy policy](https://keyjawn.amditis.tech/privacy) - [User manual](https://keyjawn.amditis.tech/manual) +- [App Review evidence](docs/ios-app-review.md) +- [Changelog](CHANGELOG.md) ## License diff --git a/SOCIAL.md b/SOCIAL.md index 5708f3a..cf69d40 100644 --- a/SOCIAL.md +++ b/SOCIAL.md @@ -1,142 +1,132 @@ -# KeyJawn — social context doc +# KeyJawn social context -This file is read by the Claude Code browser extension before each social media session. -Keep it current. Update it when features ship, things break, or the story changes. +This file is the active source for KeyJawn social posts. Update it when a +feature ships or distribution state changes. Do not turn planned, source-only, +or rejected work into a release claim. ---- +## Product -## What KeyJawn is +KeyJawn helps people work with terminal-based LLM tools from a phone. -A custom mobile keyboard built specifically for developers who use LLM CLI agents from their phone. +- Android provides a custom keyboard for terminal and agent workflows. +- iOS provides a remote SSH terminal and a companion custom keyboard. +- KeyJawn does not run a local shell or execute downloaded code on iPhone or + iPad. Commands execute on the remote SSH server that the user configures. -**The key distinction from other mobile terminal keyboards:** -Most mobile terminal keyboards (Hacker's Keyboard, etc.) are built for traditional terminal use — shell commands, SSH sessions, running htop, navigating vim. That's not the primary use case for KeyJawn. KeyJawn is built around the specific interaction pattern of working with LLM CLI agents: writing prompts, reviewing output, sending follow-ups, uploading context, managing slash commands. The workflow is less about typing `ls -la` and more about `/compact`, voice-dictating a long prompt, copying an agent's output to paste elsewhere, and sending a screenshot to your remote session. - -This shapes every feature decision: -- The slash command panel exists because `/compact`, `/clear`, `/memory`, `/cost` are typed constantly in Claude Code and Gemini CLI sessions -- Voice input is for dictating long prompts — not just quick commands -- Clipboard history exists because you're constantly copying context between your agent and other apps -- SCP upload is for sending screenshots and images into your agent's context from your phone -- The one-shot Ctrl key handles the prompt → Ctrl+C → re-prompt cycle that characterizes agent debugging - -Traditional terminal keyboards assume you're running commands. KeyJawn assumes you're in a conversation with an agent. - -**The terminal keys are still there** — Esc, Tab, Ctrl, arrows — because LLM CLI tools live in terminals and you still need them. But they're not the point. The point is that the rest of the keyboard is designed around how you actually use Claude Code or Gemini CLI, not how you'd use bash in 2010. - -The target user: anyone running Claude Code, Gemini CLI, Codex CLI, OpenCode, or similar agents from their phone. SSH power users who also use AI tooling. Termux regulars who've moved beyond shell commands into agent workflows. - ---- - -## Versions +## Distribution state ### Android -**KeyJawn Lite** — free -- Download: APK at https://keyjawn.amditis.tech (GitHub releases, not Google Play yet) -- Includes: all terminal keys, voice input, clipboard history (30 items), slash commands, swipe gestures, per-app autocorrect, alt character long-press -- No network permissions - -**KeyJawn Full** — $4 one-time purchase -- Buy at https://keyjawn.amditis.tech (Stripe, email delivery) -- Adds: SCP image upload, clipboard pinning, custom slash command sets, color themes (dark/light/OLED/terminal), menu panel -- Requires INTERNET + READ_MEDIA_IMAGES + RECORD_AUDIO +- KeyJawn Lite is free through the website and GitHub releases. +- KeyJawn Full costs $4 as a one-time website purchase. +- KeyJawn Lite has active internal and closed Google Play test tracks. +- Google Play production is inactive. Do not call the app publicly available on + Google Play. ### iOS -- Standalone app with built-in SSH terminal (SwiftTerm + SwiftNIO SSH via Citadel) -- Companion keyboard extension adds terminal keys to any app -- Currently in **TestFlight beta**: https://testflight.apple.com/join/8vMqguKK (50 spots) -- **App Store:** v1.0 was rejected (3.2.2). Listing copy and slash-command screenshot were unbranded; build 8 uploaded. Not resubmitted until that build is processed. - ---- - -## Current version - - - -**Android:** v1.3.0 -**iOS:** v1.0 (build 8 uploaded; previous 1.0 listing rejected) - -### Recent changes - - -- iOS SSH extra row: `/` opens the slash panel (including custom shortcuts), system Return and Send submit CR, long-press Send inserts a newline without submitting, Agent/Confirm presets switch the row, hardware Esc/Tab/arrows/Ctrl+C reach the PTY, mic dictation is main-app only, Clip and SCP open the real panels, and clipboard pins live in the App Group so the app and keyboard share them. -- Android typing and voice overhaul: keys fire on press instead of release (with rollover), backspace escalates to word deletion and supports swipe-to-delete, auto-capitalize at sentence starts, haptics on the number row. -- Android voice input is now continuous — dictate a whole prompt in natural sentences instead of one tap per phrase. Live transcription appears in the field as you speak, hold the mic for push-to-talk, and cancel discards instead of committing. Optional spoken punctuation ("new line", "open paren"). -- Google Play internal testing set up (7 testers). Production launch pending. -- iOS App Store 1.0 rejected for 3.2.2; listing screenshot and description unbranded, build 8 uploaded. -- Stripe + R2 purchase/delivery pipeline live. - ---- - -## Pain points KeyJawn solves - -Use these for post ideas. Be specific, not generic. Frame them around agent workflows, not traditional terminal use. - -**Agent-specific pain points (highest priority for posts):** -- You can't run Claude Code seriously from your phone on stock Android because there's no Esc, no Ctrl, no arrows. Every other keyboard makes you open a symbol panel to find them. -- Typing `/compact` or `/clear` into Claude Code by hand, every single session, on a touchscreen, gets old fast. The slash command panel inserts them in one tap. -- Dictating long prompts by voice is faster than typing them on a phone keyboard — but Android's voice input doesn't work inside SSH apps or Termux. KeyJawn's mic button works at the keyboard level, so it fires in any app. -- You want to send a screenshot to your Claude session. On most phones that's: screenshot → open SCP app → find the file → upload → switch back. With Full, it's a button in the keyboard row. -- Clipboard on Android doesn't remember what you copied three prompts ago. KeyJawn keeps 30 items in history. Full version lets you pin the ones you reuse constantly. -- The prompt → Ctrl+C → re-prompt cycle is constant when working with agents. Stock keyboards make you switch modes to get Ctrl. KeyJawn's one-shot Ctrl: tap to arm, next key fires with Ctrl, automatically resets. No mode switching. - -**General terminal pain points (also valid but less distinctive):** -- No Esc key on AOSP keyboards. Affects vim, less, any terminal tool. -- Arrow keys require a layer switch on stock keyboards. Kills your flow when navigating command history. -- Per-app autocorrect — you want it off in Termux, on in your notes app. Long-press spacebar handles this without going into Settings. - ---- - -## What's in progress / coming soon - - - -- Google Play production launch (Lite) — track progressing from internal → production -- iOS App Store launch pending review outcome -- [ADD BACKLOG ITEMS HERE as they're planned or in flight] - ---- - -## Features to highlight (rotation) - -These are specific, post-worthy features. Rotate through them — don't repeat the same one twice in a row. - -1. Three-state Ctrl key — tap for one-shot (fires once, resets), long-press for sticky/locked. No mode switching. -2. Voice input works in Termux and SSH apps — mic button in the keyboard row, not the system IME bar. -3. Clipboard history 30 items deep, accessible from keyboard. Full version adds pinning. -4. Slash command panel — type `/compact`, `/clear`, `/memory` etc. in one tap. Works in any app. -5. Number row with long-press shifted symbols. No more switching layers to type `!` or `@`. -6. Alt character long-press on QWERTY keys — accented letters, punctuation variants, etc. -7. Per-app autocorrect toggle via long-press spacebar. Off in Termux, on in Signal. -8. Arrow keys in the terminal row — scroll through command history without leaving the keyboard. -9. SCP image upload to any SSH server, configured per-host. Tap to select and upload from Files. -10. Color themes: dark, light, OLED black, terminal green. (Full version.) -11. iOS: built-in SSH terminal so you don't need a separate app. -12. iOS: keyboard extension brings terminal keys to every app on iPhone — Notes, Messages, anywhere. - ---- - -## Tone and voice notes - -These apply to any post written on behalf of KeyJawn. - -- Write like a developer who built something and is telling other developers about it. -- Be specific. "Ctrl one-shot mode fires and resets in one tap" is better than "advanced Ctrl modifier." -- No marketing language. No "excited to announce", "game-changer", "revolutionizing", "seamlessly". -- No hyperbole. If something is useful, describe why it's useful. Don't call it amazing. -- No hashtag walls. One or two at most, only if they add discovery value (#ClaudeCode #Termux). Never #dev #coding #mobiledev #keyboard #productivity #tech. -- Keep posts under 240 characters when possible. Twitter is for short takes. -- Don't start with "I" — Twitter/X's algo punishes it. Rearrange the sentence. -- Don't start with "We" either — KeyJawn is a real small product, not a corporate "we". -- Present tense, active voice. -- No emojis unless they're functional (e.g., arrows → for navigation, not decoration). - ---- +- The public TestFlight invitation is not accepting new testers as of August + 28, 2026. Do not publish the invitation URL as an active download path. +- Apple rejected App Store review build 2 under guideline 2.5.2. +- A fresh signed build 9 archive was created and verified from the corrected + source on August 31, 2026. The exact archive was uploaded, and Apple processed + build 9 as valid. Build 9 is selected for version 1.0, manual release is + enabled, and the submission is waiting for review. The app is not approved + and not publicly released. +- It is accurate to say that build 9 is waiting for review. Do not state or + imply App Store approval, public availability, or a launch date. + +## Platform features + +### Android-only features + +- A permanent Esc, Tab, Ctrl, and arrow-key row. +- A three-state Ctrl key with one-shot and locked states. +- Voice input from the keyboard. +- A 30-item clipboard history. The full version adds pinning. +- Slash-command shortcuts. +- Swipe gestures, spacebar cursor movement, backspace acceleration, and + per-app autocorrect control. +- An adaptive Enter key, double-space period insertion, automatic sentence + capitalization, and key-specific haptics. +- Full-version SCP image upload, custom command sets, themes, and menu settings. + +Do not describe these Android features as iOS keyboard features. In particular, +the iOS microphone flow is in the main app, not the keyboard extension. + +### iOS-only features + +- A built-in SSH terminal connects to a host that the user configures. +- Terminal input travels over SSH. The remote host executes commands and returns + output for display. +- The app does not provide a local iOS shell, local command interpreter, device + file browser, document picker, or file provider. +- Copied-image upload reads one image from the system pasteboard, prepares it in + memory, and writes a new file to a configured remote directory through SFTP. + The selected host must use SSH key authentication. The feature does not + browse local files or list, read, or download remote files. +- The companion keyboard provides terminal-oriented controls and text shortcuts + in apps and text fields that support third-party keyboards. It inserts text or + control sequences into the focused field. The receiving app decides how to + interpret them. The built-in SSH terminal sends terminal bytes directly. +- Basic typing and built-in text shortcuts work without Allow Full Access. Full + Access is optional for copied-image upload, shared keyboard settings, + user-created shortcuts, and clipboard history. + +iOS controls where a custom keyboard can appear. The system keyboard appears in +passcode and secure text fields, and in fields that use the `phonePad` or +`namePhonePad` keyboard type. Apps can also block third-party keyboards. Do not +say “any app,” “every app,” “system-wide,” or “anywhere.” + +## Product angle + +Traditional terminal keyboards focus on shell navigation. KeyJawn focuses on +the repeated work of using terminal-based agents: writing prompts, inserting +text shortcuts, reviewing output, moving copied context, and interrupting a +running command. + +Useful Android post topics include: + +- Insert a common slash command without typing it each time. +- Use one-shot Ctrl for the prompt, interrupt, and re-prompt cycle. +- Dictate a long prompt through the Android keyboard. +- Move the cursor by holding and dragging the Android spacebar. +- Upload an image from Android without leaving the current text workflow. + +Useful iOS post topics include: + +- Explain the remote SSH boundary: input leaves the device, the server executes + the command, and output returns over the connection. +- Show the built-in remote terminal. +- Show terminal-oriented controls in a normal text field that supports third-party + keyboards, and explain that the receiving app decides how to interpret them. +- Explain what works without Allow Full Access. +- Explain copied-image upload without calling it a local file browser. + +## Writing rules + +- Write as one developer talking to another. +- Use short, direct sentences and active voice. +- State the platform for every platform-specific feature. +- Describe shipped behavior. Do not convert plans or source builds into launch + claims. +- Do not say that KeyJawn executes code on iOS. The configured remote host + executes commands. +- Do not say that the iOS app can browse Files, browse device storage, or select + an upload from Files. +- Do not say that copied-image upload works with a password-authenticated host. +- Do not describe iOS extension controls as hardware key events. +- Do not say that the iOS keyboard appears in every app or field. +- Do not say that Full Access is required for basic typing. +- Do not use hype, fake emotion, rhetorical questions, competitor attacks, or + hashtag walls. +- Keep posts below 240 characters when the idea fits. Use at most one relevant + hashtag. +- Put a link at the end when it helps the reader. ## Links - Website: https://keyjawn.amditis.tech - GitHub: https://github.com/jamditis/keyjawn -- iOS TestFlight: https://testflight.apple.com/join/8vMqguKK +- iOS status: https://keyjawn.amditis.tech/changelog - Twitter: @keyjawn diff --git a/docs/app-store-v1.0-metadata.md b/docs/app-store-v1.0-metadata.md new file mode 100644 index 0000000..d7f0603 --- /dev/null +++ b/docs/app-store-v1.0-metadata.md @@ -0,0 +1,195 @@ +# App Store v1.0 metadata + +> **Status:** Submitted for review. Apple rejected review build 2 under +> guideline 2.5.2. +> A fresh signed build 9 archive was created and verified from the corrected +> source on August 31, 2026. The exact archive was uploaded, and Apple processed +> build 9 as valid. Build 9 is selected for version 1.0, manual release is +> enabled, and the submission is waiting for review. The reviewer response and +> test steps were included in the review notes. The app is not approved and not publicly released. + +Use this file as the exact copy source for the next approved App Store Connect +update. Replace every bracketed placeholder with a verified value. Do not add +passwords, private keys, recovery codes, or other credentials to this file. + +## Product page copy + +### Name + +```text +KeyJawn +``` + +### Subtitle + +```text +Remote terminal and keyboard +``` + +### Promotional text + +```text +Connect to a remote SSH server, work in a terminal, and use a keyboard for terminal-oriented controls and text shortcuts where iOS supports third-party keyboards. +``` + +### Description + +```text +KeyJawn is an SSH client and companion custom keyboard for iPhone and iPad. It is made for people who work with terminal-based tools from a mobile device. + +Remote SSH terminal + +Connect to a server that you configure. Commands execute on that remote server. KeyJawn sends terminal input over SSH and displays the output returned by the server. It does not provide a local iOS shell or browse files on the iPhone or iPad. + +Optional voice input is available in the built-in SSH terminal. When you tap Mic, KeyJawn asks for microphone and speech-recognition permission. Apple's Speech framework can process the audio. KeyJawn does not store audio recordings or send them to a server operated by the developer. The keyboard extension does not use the microphone. + +Custom keyboard + +Use QWERTY input, terminal-oriented controls, and text shortcuts in apps and text fields that support third-party keyboards. The extension inserts text or control sequences into the focused field. The receiving app decides how to interpret them. The built-in SSH terminal sends terminal bytes directly. Basic typing and built-in text shortcuts work without Allow Full Access. User-created shortcuts require Full Access because the app and extension share them through the App Group. + +iOS controls where a custom keyboard can appear. The system keyboard appears for passcodes, secure text fields, and phone-pad fields. An app can also block third-party keyboards. + +Optional copied-image upload + +Copy an image, then upload it through SFTP to a directory on a remote host that you configure for SSH key authentication. KeyJawn reads the copied image from the system pasteboard, prepares it in memory, and writes a new remote file. The copied-image upload feature does not browse local device files or list, read, or download remote files. Password authentication remains available for terminal connections. + +Privacy and control + +SSH identity data is stored in the Keychain. Host settings and keyboard preferences are stored in the app's containers. KeyJawn does not provide a local command runtime. +``` + +### Keywords + +```text +ssh,terminal,remote,server,keyboard,cli,developer,commands,esc,tab,ctrl +``` + +### URLs + +```text +Support URL: https://keyjawn.amditis.tech/support +Marketing URL: https://keyjawn.amditis.tech +Privacy policy URL: https://keyjawn.amditis.tech/privacy +``` + +### Copyright + +```text +[copyright year] [legal owner name] +``` + +## App Review information + +### Contact fields + +```text +First name: [review contact first name] +Last name: [review contact last name] +Phone number: [review contact phone number] +Email: [review contact email] +``` + +### Sign-in information + +Enter review credentials only in the protected App Store Connect fields. Do +not put them in this repository. + +```text +Sign-in required: Yes +Username: [isolated review username; enter only in App Store Connect] +Password: [isolated review password; enter only in App Store Connect] +``` + +### Review notes + +Use this exact draft only after the approved revised build is uploaded and +selected. Replace all placeholders first. + +```text +KeyJawn is a remote SSH client. It does not execute commands locally on iOS. Terminal input is sent over SSH to a server configured by the user. That server executes the command, and KeyJawn displays the output returned over the network. + +The Mic action is available only in the built-in SSH terminal. It requests microphone and speech-recognition permission when the reviewer taps it. Apple's Speech framework can process the captured audio. KeyJawn does not store audio recordings or send them to a server operated by the developer. The keyboard extension does not use the microphone. + +KeyJawn has no local shell, local command interpreter, document picker, document browser, or file provider. The copied-image upload reads one image that the user has placed on the system pasteboard, prepares it in memory, and writes a new file through SFTP to a configured directory on the remote SSH host. The copied-image upload feature cannot browse local device files or list, read, or download remote files. + +The companion keyboard can type and insert built-in text shortcuts without Allow Full Access. User-created shortcuts require Full Access because the app and extension share them through the App Group. The extension inserts text or control sequences into the focused field, and the receiving app decides how to interpret them. The built-in SSH terminal sends terminal bytes directly. Full Access is optional. KeyJawn uses it only for copied-image upload and shared keyboard settings, user-created shortcuts, or clipboard history. Copied-image upload also requires a host configured for SSH key authentication. iOS uses the system keyboard for passcodes, secure text fields, and fields that use the phonePad or namePhonePad keyboard type. Apps can also block third-party keyboards. + +Review connection: +Host: [review host] +Port: [review port] +Username: [review username] +Transport: Enable TLS tunnel +Authentication: Password for the initial terminal connection; SSH key after the fresh install's public key is authorized +Expected host-key fingerprint: [fingerprint] +Writable remote directory: [remote directory] + +Test steps: +1. Launch KeyJawn and complete or skip onboarding. +2. Open Settings > SSH keys and tap Copy public key. This is the public key generated by this fresh installation. Do not send or copy the private key. +3. Add the review host with the values above, enable TLS tunnel, and select Password authentication. +4. Connect with the protected review password. Confirm that the displayed host-key fingerprint matches the value above. +5. Enter: printf 'keyjawn-review\n' +6. Confirm that the remote host returns keyjawn-review in the terminal. +7. Tap Mic. Allow speech recognition and microphone access if iOS asks. Speak a short phrase, tap Mic again, and confirm that the transcription appears in the terminal input before you send it. +8. To authorize this installation for the optional upload test, enter this prefix without submitting it: mkdir -p ~/.ssh && chmod 700 ~/.ssh && printf '%s\n' ' +9. Tap Clip and select the public key copied in step 2. Then enter this suffix and submit the complete command: ' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys +10. Disconnect, edit the review host, and select SSH key authentication. +11. In Copied-image upload, set Upload path to the writable remote directory above, save the host, and reconnect. +12. Copy a small image, tap the SCP key to open Upload copied image, select the review host, and confirm that KeyJawn inserts a new path in the writable remote directory. +13. To test the keyboard, enable KeyJawn in Settings > General > Keyboard > Keyboards, then select it in a text field that supports third-party keyboards. Basic typing and built-in shortcuts work without Allow Full Access. User-created shortcuts, shared clipboard history, and copied-image upload require Full Access. The extension inserts text or control sequences; the receiving app decides how to interpret them. +``` + +## Reviewer response draft + +The approved response was included in the App Review notes with the connection +details and test steps. No separate Resolution Center message was sent because +the public App Store Connect API does not provide that message surface. + +Use this exact draft only after the approved revised build is uploaded and +selected. Replace `[approved build number]` before sending it. + +```text +Hello App Review, + +We addressed the guideline 2.5.2 concern in build [approved build number]. + +KeyJawn does not execute commands locally on iOS. Its terminal sends input over SSH to a remote server configured by the user. The remote server executes the command, and KeyJawn displays the output returned over the network. We removed the session-free Preview terminal so this boundary is clear before a host exists. + +KeyJawn does not provide a local file browser, document picker, file provider, or security-scoped file access. Copied-image upload reads one image that the user placed on the system pasteboard, prepares it in memory, and writes a new file through SFTP to a configured directory on the remote SSH host. The copied-image upload feature cannot browse local device files or list, read, or download remote files. + +Current SSH identity data is stored in the Keychain. The only direct production file read is a fixed legacy SSH identity file inside KeyJawn's entitled App Group container. This read exists only to migrate older installs to the shared Keychain. KeyJawn deletes the legacy file only after it verifies the Keychain write. + +Voice input is available only in the built-in SSH terminal. It requests microphone and speech-recognition permission after the user taps Mic. Apple's Speech framework can process the captured audio. KeyJawn does not store audio recordings or send them to a server operated by the developer. The keyboard extension does not use the microphone. + +The keyboard can type and insert built-in text shortcuts without Allow Full Access. User-created shortcuts require Full Access because the app and extension share them through the App Group. It inserts text or control sequences into the focused field; the receiving app decides how to interpret them. The built-in SSH terminal sends terminal bytes directly. Full Access is optional and is used only for copied-image upload and shared keyboard settings, user-created shortcuts, or clipboard history. Copied-image upload requires a host configured for SSH key authentication; password authentication remains available for terminal connections. + +Review connection details and test steps are in the App Review notes for this build. + +Thank you. +``` + +## Screenshot captions + +```text +Commands run on the remote SSH server you configure +Basic typing and built-in shortcuts work without Full Access +Add the remote SSH hosts you control +Configure keyboard and SSH settings +Use KeyJawn as a companion keyboard in supported text fields +``` + +## Submission checks + +- Confirm that every placeholder has a verified value. +- Confirm that review credentials exist only in App Store Connect. +- Confirm that the selected build number matches both signed targets. +- Confirm that screenshots show the current build and no third-party branding. +- Confirm that the privacy answers match the signed app and extension, including + the built-in terminal's optional use of Apple's Speech framework. Apple says + developers do not report data collected only by Apple through Apple + frameworks or services; verify the current answers against Apple's current + App Privacy questions instead of assuming that Apple-only processing is + developer collection. +- Confirm the Mic permission and transcription steps on reviewer-class hardware. +- Get separate user approval for metadata changes, the reviewer response, and + resubmission. diff --git a/docs/claude/worker-readme.md b/docs/claude/worker-readme.md index a548e61..d826e22 100644 --- a/docs/claude/worker-readme.md +++ b/docs/claude/worker-readme.md @@ -1,34 +1,34 @@ -# Keyjawn worker — operational reference +# KeyJawn worker operational reference Autonomous social media agent for keyjawn. Monitors Twitter, Bluesky, and Product Hunt, curates dev tool content, and posts/engages with Telegram approval. --- -## CRITICAL: deployment requirements +## Deployment requirements -**The worker MUST run on officejawn.** It cannot run on houseofjawn. +The worker runs on `houseofjawn` as `keyjawn-worker`. It moved from +`officejawn` because the campus network blocked social-platform traffic. -Two reasons: - -1. **Virtual desktop (DISPLAY=:99)** — The social-scroller uses Playwright to drive a real Chrome browser. That browser runs on officejawn's `virtual-desktop.service` (Xvfb + LXDE + x11vnc). Houseofjawn has no virtual display. - -2. **CDP endpoint** — The Chrome browser exposes `http://127.0.0.1:9222` (Chrome DevTools Protocol). This is localhost-relative. Running the script on any other machine means it can't reach the browser. - -**`SocialScrollerConfig.ssh_host` must be `""` (empty).** An empty `ssh_host` tells `SocialScrollerClient._run_cmd()` to execute the script locally, not via SSH. If someone sets this to `"houseofjawn"`, the social-scroller will fail silently with exit code 1 — the CDP connection times out and no posts are extracted. +```bash +sudo systemctl status keyjawn-worker +sudo journalctl -u keyjawn-worker -f +``` -This was a production bug from 2026-02-18. The logs showed `social-scroller exited 1` every scan cycle until caught. The config default is now fixed but is worth knowing about. +The worker does not use the Playwright social-scroller in the active setup. +`SocialScrollerConfig.search_platforms` is empty. Scheduled browser engagement +runs separately through the Claude Code browser extension on `houseofjawn`. -**Service status:** `sudo systemctl status keyjawn-worker` (on officejawn) -**Service logs:** `sudo journalctl -u keyjawn-worker -f` +Do not enable the legacy social-scroller from this document. Verify its host, +display, Chrome debugging endpoint, and network path before any separate +reactivation plan. --- ## Architecture ``` -social-scroller.py (DISPLAY=:99, CDP:9222) - └── reads Twitter, Bluesky, Reddit, YouTube feeds via real browser - └── returns JSON → SocialScrollerClient → monitor.queue_new_findings() +Legacy social-scroller.py + └── disabled in the active configuration Twitter API (twikit, cookie-based) Bluesky API (atproto) @@ -64,7 +64,10 @@ The social scroller supplements the API clients — it can reach platforms (Redd --- -## Social-scroller setup +## Legacy social-scroller reference + +> **Status:** Inactive. These commands describe the old `officejawn` setup. They +> are not deployment instructions for the active worker. **Script location:** `~/social-scroller.py` (on officejawn) **Virtual desktop:** `sudo systemctl status virtual-desktop` — must be running @@ -97,9 +100,25 @@ python3 ~/social-scroller.py status Posts are generated by Gemini CLI (`gemini -p `). The product description in `worker/content.py:build_generation_prompt()` is what Gemini knows about keyjawn. **When new features ship or the product changes, update that string.** It's the single source of truth for post content. Current product knowledge includes: -- Android: Esc/Tab/Ctrl/arrow row, voice input, SCP upload, slash commands, $4 purchase -- Android UX (v1.2+): backspace acceleration, spacebar cursor drag, adaptive Enter key, double-tap space → period, auto-capitalize, differentiated haptics -- iOS: in private beta testing, not publicly available. Building SSH terminal + keyboard extension. App Store TBD. + +- Android-only: Esc, Tab, Ctrl, and arrow row; voice input; slash commands; + backspace acceleration; spacebar cursor drag; adaptive Enter; double-space + period; automatic capitalization; and key-specific haptics. +- Android Full only: SCP image upload and the paid feature set. +- iOS-only: a remote SSH terminal and companion keyboard. Commands execute on a + configured remote host. KeyJawn has no local iOS shell or device-file browser. +- iOS keyboard: basic typing works without Allow Full Access and the keyboard + appears only where iOS and the host app allow third-party keyboards. The + extension inserts text or control sequences, and the receiving app decides + how to interpret them. +- iOS copied-image upload: the selected host must use SSH key authentication. + Password authentication remains available for terminal connections. +- App Store: Apple rejected review build 2 under guideline 2.5.2. A fresh signed + build 9 archive was created and verified from the corrected source on August + 31, 2026. The exact archive was uploaded, and Apple processed build 9 as + valid. Build 9 is selected for version 1.0, manual release is enabled, and the + submission is waiting for review. The app is not approved and not publicly + released. **Writing constraints** baked into every prompt: developer-to-developer voice, no rhetorical questions, no hashtag spam, no exclamation marks, no hype words, max 1 hashtag, max 2 emoji. See `BANNED_WORDS` and `BANNED_OPENERS` in `content.py` — validation runs after generation and will reject non-compliant posts. @@ -115,7 +134,9 @@ Defined in `worker/calendar_gen.py:TOPICS`. The scheduler cycles through these i - **social_proof** — milestones, user feedback, GitHub activity - **behind_scenes** — dev updates, philosophy, iOS progress -When a major feature ships: add a `demo` topic and an `awareness` topic. When iOS goes public: update the product description in `content.py` and add a `social_proof` topic for the launch. +When a major feature ships, add a `demo` topic and an `awareness` topic. Before a +distribution post, verify the live store state. Do not use a source build or a +draft submission as launch evidence. --- @@ -153,8 +174,8 @@ cd worker && python -m worker.manage | `worker/approvals.py` | Telegram approval flow | | `worker/discovery.py` | Curated account list + engagement classification | | `worker/curation/` | RSS/YouTube/Twitch pipeline | -| `worker/config.py` | All config dataclasses — `SocialScrollerConfig.ssh_host` must be `""` | -| `~/social-scroller.py` | Browser automation script (runs locally on officejawn) | +| `worker/config.py` | All config dataclasses; active social-scroller platforms are empty | +| `~/social-scroller.py` | Legacy browser automation script; inactive in the current setup | --- diff --git a/docs/handoffs/2026-07-25-two-milestone-release.md b/docs/handoffs/2026-07-25-two-milestone-release.md index c4eafaa..dbcd288 100644 --- a/docs/handoffs/2026-07-25-two-milestone-release.md +++ b/docs/handoffs/2026-07-25-two-milestone-release.md @@ -1,5 +1,10 @@ # Two-milestone release handoff +> **Status:** Historical handoff. Do not use this file as current build, +> distribution, merge, or approval state. See [`../../CLAUDE.md`](../../CLAUDE.md), +> [`../../CHANGELOG.md`](../../CHANGELOG.md), and +> [`../../tasks/todo.md`](../../tasks/todo.md). + Last updated from landofjawn on 2026-07-25. No pull request has been merged and no release has been built or deployed. Joe diff --git a/docs/handoffs/2026-08-27-app-review-remediation-pause.md b/docs/handoffs/2026-08-27-app-review-remediation-pause.md new file mode 100644 index 0000000..ee11914 --- /dev/null +++ b/docs/handoffs/2026-08-27-app-review-remediation-pause.md @@ -0,0 +1,212 @@ +# App review remediation pause handoff + +> **Status:** Safe pause on August 27, 2026, at the end of the local iPhone +> verification run. Read this file and [`../../tasks/todo.md`](../../tasks/todo.md) +> before changing the working tree. This handoff is not approval to archive, +> upload, edit store metadata, reply to App Review, or resubmit. + +## Repository state + +- Repository: `/Users/jamditis/Desktop/Crimes/playground/keyjawn` +- Branch: `main` +- Checked-out commit: `e0aff639ce8a251aa3e2fff27e4be21ab197b6c6` +- The latest GitHub state was pulled before this remediation began. +- The working tree contains the full uncommitted remediation. It has 78 tracked + files with changes plus new files. Do not reset, clean, stash, regenerate, or + pull before reviewing `git status`, this handoff, and `tasks/todo.md`. +- `git diff --check` passed at the pause. +- No Xcode test process remained active at the pause. + +No external release mutation occurred. The session did not change a build +number, create an archive, upload a build, edit App Store Connect metadata, +send a reviewer response, cancel or resubmit a submission, create a tag, upload +to Google Play, or promote a Play track. + +## Current Apple state + +- App Store Connect app ID: `6759345867`. +- Review submission ID: `83b2805c-650c-4bf6-91ff-0c6339f36324`. +- Apple rejected review build 2 under guideline 2.5.2 on June 12, 2026. +- At this handoff, both checked-in iOS targets remained at source build 8. The + later approved release pass selected unused source build 9 for both targets. +- The remediation removes the session-free local Preview path and documents the + real boundary: KeyJawn sends terminal input to a configured remote SSH host; + the remote host executes commands and returns output. +- The custom keyboard works outside the KeyJawn app in apps and text fields that + permit third-party keyboards. It inserts text or control sequences. The + receiving app decides how to interpret them. iOS blocks custom keyboards in + secure and some phone-pad fields, and an app can block them. + +## Finished local work + +- Removed the Preview/local-echo path, unused Photos key, unused URL scheme, + unused accent reference, and confirmed dead code. +- Kept local device storage inside the app, extension, App Group, and Keychain + boundary. The only direct production file read is fixed legacy SSH-key + migration inside the entitled App Group. +- Restricted copied-image upload to SSH-key hosts at the model, UI, and uploader + layers. Password authentication remains available for terminal connections. +- Kept Full Access optional for basic typing and built-in shortcuts. Shared + settings, user-created shortcuts, clipboard history, and copied-image upload + use Full Access. +- Set `IsASCIICapable` to true for the English QWERTY extension. +- Forwarded terminal emulator replies through the live remote SSH session. +- Isolated UI-test persistence and isolated every store test from the repository + SQLite database. +- Added the ignored SQLite sidecar pattern `store/keyjawn-store.db-*`. +- Ran a live key-authenticated SSH, host-trust, PTY, resize, and SFTP test against + `houseofjawn`. The uploaded bytes matched, and the temporary remote file and + marked authorized key were removed. +- Updated README, changelog, social source, maintainer docs, App Store draft, + website, privacy text, support text, Play release guide, and screenshot docs. +- Created five current App Store JPEGs for iPhone and five for iPad. All have + accepted dimensions, no alpha channel, no credentials, no private hosts, and + no third-party branding. +- Updated the website to Astro 7.2.9 and Node 22.19 or later. The site builds 10 + pages, has an SVG favicon and Open Graph metadata, and `npm audit` reports zero + vulnerabilities. +- Verified Android API 36, version code 10, version name 1.4.0, and package + `com.keyjawn.lite`. The full Android suite passed 425 tests; lite passed 421. + Both lint variants reported zero errors. + +## Final review work completed tonight + +The full local Claude review session was +`523c158c-8114-4509-b041-5bf85ed8c0c7`. It finished after 52 read-only review +turns and found no P1. It found two P2 documentation gaps: + +1. The App Review evidence and metadata did not disclose the built-in terminal's + optional microphone and Speech framework path. +2. The copy said all text shortcuts worked without Full Access, but user-created + shortcuts use the shared App Group and need Full Access in the extension. + +Both P2 findings are fixed. The App Review draft now explains voice permissions, +Apple Speech processing, the no-recording boundary, and an exact reviewer Mic +test. It now says built-in shortcuts work without Full Access and user-created +shortcuts need it. `AppReviewBoundaryTests` protects both disclosures. + +Claude also found two P3 items. The stale comment that said Esc replaced Mic is +fixed. A failing test proved that plain `é` became a lossy byte and Ctrl+`é` +became an unrelated control byte. `ANSISequence` now emits plain text as UTF-8 +and applies Ctrl masking only to one ASCII scalar. + +## Verification at the pause + +- Focused final run: 21 tests passed. This includes 11 ANSI sequence tests and + 10 App Review boundary tests. +- Final iPhone 15 Pro Max/iOS 17.5 unit run: 184 executed, 183 passed, 1 expected + live SSH test skipped, 0 failed. +- iPhone result bundle: + `/tmp/keyjawn-iphone-units-20260827-2233.xcresult` +- The matching iPad run did not start tests. It was launched concurrently and + Xcode stopped it because both processes used the same shared build database. + This was an orchestration failure, not a product test failure. +- Failed iPad result bundle: + `/tmp/keyjawn-ipad-units-20260827-2233.xcresult` +- Before the last two tests were added, the iPad Pro 13-inch (M4)/iOS 17.5 suite + passed 181 tests with 1 expected live skip. The final-code iPad rerun remains + required. +- All five UI tests passed earlier on the iPhone 15 Pro Max/iOS 17.5 and iPad Pro + 13-inch (M4)/iOS 17.5. The last changes did not alter those UI flows. +- Store tests: 16 passed. +- Worker tests: 160 passed and 12 expected skips. +- App Store field lengths: name 7/30, subtitle 28/30, promotional text 162/170, + description 2,071/4,000, and keywords 71/100. +- `git diff --check` passes. The stale no-Full-Access and Mic-comment claims are + absent. +- Strict Swift formatting still reports older alignment style in touched files. + The new guard follows formatter output. Do not apply a broad formatter rewrite + as part of the App Review fix without reviewing that separate diff. + +## Important local database disclosure + +An early store test run used the ignored local database before test isolation +was fixed. It modified `store/keyjawn-store.db` at about 21:31 on August 27 and +left test rows. No trusted baseline exists, so the file was not deleted, +restored, or changed afterward. The source cause is fixed: collection-time and +per-test database paths now use temporary databases, and the full store suite +passes without changing the repository database. + +Recorded database state after the fix: + +```text +sha256: bc4a1424830eee91980d2c9ad6b55390ca5c4d381bfff1bcfe65263871109a8b +mtime: 1787880715 +size: 28672 bytes +``` + +Treat the current ignored database as user state with known test contamination. +Do not delete or replace it without the user's direction. + +## First steps tomorrow + +1. Read this file and `tasks/todo.md`. Run `git status --short` and + `git diff --check`. Do not clean the working tree. +2. Use `git fetch origin` only as a read-only freshness check. Compare the dirty + checkout with `origin/main` before deciding whether any integration is safe. +3. Rerun the final iPad unit suite sequentially with a separate derived-data + directory: + + ```bash + cd /Users/jamditis/Desktop/Crimes/playground/keyjawn/ios + xcodebuild test \ + -project KeyJawn.xcodeproj \ + -scheme KeyJawn \ + -destination 'platform=iOS Simulator,id=2C2000E7-5878-41DF-9D73-60DB7C4822FD' \ + -derivedDataPath /tmp/keyjawn-ipad-derived-20260828 \ + -resultBundlePath /tmp/keyjawn-ipad-units-20260828.xcresult \ + -only-testing:KeyJawnKitTests + ``` + + Expected result: 184 executed, 183 passed, 1 expected live skip, 0 failed. +4. Run a fresh read-only Claude review of the final diff. Ask it to confirm that + the two P2 findings are closed and that no P1 or P2 remains. Do not mark the + final Claude checkbox until this follow-up finishes. +5. Inspect the current App Privacy answers in App Store Connect without + publishing changes. Compare them with Apple's current App Privacy guidance + and the signed voice implementation. +6. Use the user's physical iPad if available for the Mic permission, + transcription, and rotation flow. The simulator is enough for the system + keyboard and Full Access checks. +7. Obtain explicit authority before creating or changing an isolated reviewer + account on `houseofjawn`. Then dry-run password login, fresh public-key + authorization, key login, voice, shortcuts, clipboard, copied-image upload, + cleanup, and rotation. +8. If the iPad run and follow-up review pass, update `tasks/todo.md`. Stop again + before the build number, archive, upload, reviewer response, metadata edit, + or resubmission gates. + +## Remaining external gates + +- Apple: verify current App Privacy answers. +- Apple: record a current export-compliance determination. Verify + `ITSAppUsesNonExemptEncryption` and any required documentation before upload. +- Apple: obtain explicit approval before selecting Publish for App Privacy + answers. +- Apple: complete the isolated reviewer-account UI flow. +- Apple: choose an unused build number only with approval and update both targets + together. +- Apple: obtain separate approval for archive, upload, metadata changes, + reviewer response, submission cancellation, and resubmission. +- Website publication: a push to `main` that changes `website/**` runs + `.github/workflows/deploy-site.yml` and publishes the site. Obtain explicit + approval before that push. +- Google Play: add at least five more opted-in closed testers and keep at least + 12 opted in for 14 days before applying for production access. +- Google Play: obtain approval before any tag, upload, track promotion, or + production-access application. + +## Recoverable temporary material + +- Stale public assets: + `/tmp/keyjawn-stale-public-assets-20260827.H9zFl5` +- Non-uploadable Android verification artifacts: + `/tmp/keyjawn-nonuploadable-android-artifacts.uwMniA` +- Python build debris: + `/tmp/keyjawn-python-build-debris.80TXzI` +- Incomplete worker virtual environment: + `/tmp/keyjawn-worker-incomplete-venv-20260827` +- Other verification artifacts: + `/tmp/keyjawn-verification-artifacts-20260827-2207` + +Do not treat any temporary Android bundle or key as a Play upload artifact. diff --git a/docs/ios-app-review.md b/docs/ios-app-review.md new file mode 100644 index 0000000..51ae5bf --- /dev/null +++ b/docs/ios-app-review.md @@ -0,0 +1,271 @@ +# iOS App Review evidence + +This file records the code boundary for the guideline 2.5.2 remediation. It is +evidence for maintainers and a source for the next review note. It does not +authorize a build-number change, archive, upload, reply, or resubmission. + +## Current review state + +- App Store Connect app ID: `6759345867`. +- Review submission ID: `83b2805c-650c-4bf6-91ff-0c6339f36324`. +- Apple sent the current guideline 2.5.2 rejection on June 12, 2026. +- The review message identifies build 2 and an iPad Air 11-inch (M3). +- The checked-in source identifies as build 9. A fresh signed build 9 archive + was created and verified from the corrected source on August 31, 2026. The + exact archive was uploaded, and Apple processed build 9 as valid. Build 9 is + selected for version 1.0, manual release is enabled, and review submission + `83b2805c-650c-4bf6-91ff-0c6339f36324` is waiting for review. The reviewer + response and test steps were included in the review notes. The app is not + approved and not publicly released. +- Before upload, the live App Store Connect API listed builds 1, 2, and 8. The + user approved unused build number 9, and both targets were updated together. + +Apple's rule says apps must be self-contained and must not read or write data +outside their designated container, or download, install, or execute code that +changes app behavior. See [App Review guideline 2.5.2](https://developer.apple.com/app-store/review/guidelines/#software-requirements). +The container restriction applies to local device storage. Copied-image upload +is user-directed network output to a remote host. + +## Shipped code boundary + +### Remote terminal + +- `SSHSession` connects to a host that the user configures. +- The app and keyboard extension use Apple's Network.framework transport through + SwiftNIO Transport Services. This avoids the physical-iOS connection stall + reproduced with the POSIX bootstrap. +- Plain SSH remains the default. A per-host TLS tunnel option supports public + TLS-terminated TCP relays. SSH authentication and host-key verification still + occur inside the TLS connection. The configured hostname or IP address must + match the system-trusted TLS certificate. Self-signed certificates are not + supported for this path. Plain `ssh-keyscan` cannot cross the TLS relay, so + users review the SSH fingerprint on first connection instead. +- `TerminalInputView` maps text and terminal keys to bytes. +- `TerminalViewController` sends those bytes through the active `SSHSession`. +- The remote SSH server executes commands. KeyJawn displays bytes returned by + that server. +- The app has no local shell, process API, command interpreter, WebView runtime, + document picker, document browser, file provider, or security-scoped URL path. +- The old local-echo Preview tab and its session-free terminal branch are not in + the shipped app. + +### Copied-image upload + +- The app and keyboard read one image from `UIPasteboard`. +- `PasteboardImagePreparer` downsamples and encodes the image in memory. +- `CitadelSCPUploader` opens an SFTP file with write, create, and truncate flags + at the configured remote directory. It writes the prepared bytes and closes + the connection. +- Copied-image upload lists and accepts only hosts configured for SSH key + authentication. Password authentication remains available for terminal + connections, but the uploader does not receive or store a password. +- This flow does not list, browse, read, or download remote files. +- This flow does not request Photos access. The user copies an image before + opening the upload panel. + +### Voice input in the built-in terminal + +- The main app shows a Mic action in its built-in SSH terminal. The keyboard + extension does not use the microphone. +- KeyJawn requests speech-recognition and microphone permission only after the + user taps Mic. +- The main app sends captured audio to Apple's Speech framework for live + transcription. Apple's speech-recognition process can send voice audio to + Apple for processing. See Apple's + [speech-recognition permission documentation](https://developer.apple.com/documentation/speech/asking-permission-to-use-speech-recognition). +- KeyJawn does not store audio recordings or send them to a server operated by + the developer. It inserts the returned transcription into the active remote + terminal input. + +### Local storage + +- Current SSH identity data is in the Keychain access group declared in both + signed targets. +- Settings, host records, and clipboard history use the entitled App Group suite + where sharing between the app and keyboard is required. +- The only direct production file read is migration compatibility for the fixed + file `ssh-identity-ed25519.key` in the entitled `group.com.keyjawn` container. +- The main app reads that fixed file only to migrate an older install to the + shared Keychain. It deletes the legacy file only after a verified Keychain + write. +- Production code does not enumerate local directories or accept a local file + path from the user. + +### Keyboard full access and availability + +- Basic keyboard input uses `textDocumentProxy` and works without Allow Full + Access. +- Built-in slash shortcuts insert plain text into the field that has focus and + work without Full Access. User-created shortcuts use the shared App Group and + require Full Access in the extension. +- Other extension controls insert text or control sequences through the focused + field. The receiving app decides how to interpret them. The built-in SSH + terminal sends terminal bytes directly. +- Full Access is optional. KeyJawn uses it only for optional extension features + that upload a copied image or read shared keyboard settings, user-created + shortcuts, and clipboard history. +- `RequestsOpenAccess` remains enabled for those optional extension features. + See Apple's + [custom keyboard open-access documentation](https://developer.apple.com/documentation/uikit/configuring-open-access-for-a-custom-keyboard). +- `IsASCIICapable` is `true` because the QWERTY layout generates standard ASCII + characters, as required by Apple's + [custom keyboard configuration documentation](https://developer.apple.com/documentation/uikit/creating-a-custom-keyboard). +- The keyboard appears only in apps and text fields that allow third-party + keyboards. iOS uses the system keyboard for passcodes and secure text + fields, and for fields that use the `phonePad` or `namePhonePad` keyboard + type. An app can also block custom keyboards. See + Apple's [custom keyboard documentation](https://developer.apple.com/documentation/uikit/configuring-a-custom-keyboard-interface). + +## Regression evidence + +`AppReviewBoundaryTests` is a conservative source-text check tied to the local +checkout. It scans comments and string literals as well as code, so it can reject +a forbidden API name even when the name is not executable. It checks that shipping +source does not add local execution, document access, file-provider, +security-scoped URL, or runtime-loader APIs. It also limits direct filesystem APIs +to the exact legacy migration path, checks that the app has no Photos purpose +key, and checks that target metadata declares no custom URL scheme. + +`OnboardingTests`, `TerminalInputMappingTests`, and `LaunchFlowTests` check the +visible remote-execution, local-file, copied-image, and remote-host wording. + +Run the focused boundary tests from `ios/`: + +```bash +xcodebuild test \ + -project KeyJawn.xcodeproj \ + -scheme KeyJawn \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + -only-testing:KeyJawnKitTests/AppReviewBoundaryTests \ + -only-testing:KeyJawnKitTests/OnboardingTests \ + -only-testing:KeyJawnKitTests/TerminalInputMappingTests \ + -only-testing:KeyJawnKitTests/TerminalInputViewTests +``` + +Run the system-keyboard and launch UI tests on the iPhone and the iPad review +class: + +```bash +xcodebuild test \ + -project KeyJawn.xcodeproj \ + -scheme KeyJawn \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + -only-testing:KeyJawnUITests +``` + +The UI suite passed all five tests on iPad Air 11-inch (M3)/iOS 18.5 and iPhone +17 Pro/iOS 26.2. The final screenshot-device run also passed all five tests on +iPad Pro 13-inch (M4)/iOS 17.5 and iPhone 15 Pro Max/iOS 17.5. The keyboard +tests install KeyJawn in Settings, set Full Access explicitly off and on, select +the extension, require its extra row, and insert `hi`. If the iPhone simulator +hides its software keyboard, use Simulator > I/O > Keyboard > Toggle Software +Keyboard or Command-K before the run. + +`SSHLiveIntegrationTests` is opt-in. A normal suite checks the KeyJawn identity +format and skips the network test. To enable the network test, provide these +variables to the simulator test process: + +- `KEYJAWN_LIVE_SSH_HOST` +- `KEYJAWN_LIVE_SSH_USER` +- `KEYJAWN_LIVE_SSH_PORT`, or omit it for port 22 +- `KEYJAWN_LIVE_SSH_HOST_KEY`, in OpenSSH format +- `KEYJAWN_LIVE_SSH_UPLOAD_PATH`, or omit it for `/tmp` +- `KEYJAWN_LIVE_SSH_TLS=1` for a TLS-terminated SSH relay, or `0` for plain SSH + +Authorize the simulator's KeyJawn public key on an isolated test account first. +The live test starts without a saved host key, verifies the presented key, opens +a pinned Ed25519-authenticated PTY, checks remote command output, sends a resize, +and uploads a generated payload through `CitadelSCPUploader`. It prints the +success marker only after it reads the exact payload, removes the remote file, +and verifies that the file is absent. It does not print the remote path or +payload token. Remove the temporary authorized key after the run. + +The August 27, 2026 live run passed against an isolated review host. The remote payload +matched byte-for-byte. The uploaded file and marked temporary authorized key +were removed after verification. No server credentials or keys are stored in +this repository. + +Run the source-text tests in a simulator from the same checkout. Inspect the +built app and extension plists and entitlements after every project or signing +change; the source-text tests do not replace built-product inspection. + +Apple says developers do not report data collected only by Apple through Apple +frameworks or services. It also defines Audio Data as voice or sound recordings. +Before submission, compare the current App Privacy answers with the signed voice +implementation and Apple's current +[App Privacy guidance](https://developer.apple.com/app-store/app-privacy-details/). +Do not infer a developer Audio Data collection claim from Apple-only processing, +and do not omit Audio Data if the developer or an integrated third party stores +readable audio beyond the live request. + +## Draft App Store copy + +The exact product-page copy, review notes, reviewer response, test steps, and +credential-free placeholders are in +[`app-store-v1.0-metadata.md`](app-store-v1.0-metadata.md). + +That file is a draft, not a record of an App Store Connect change. Do not use its +reviewer response until an approved revised build is uploaded and selected. Put +review credentials only in protected App Store Connect fields. + +## Release gates + +Before any resubmission: + +- Finish the unit, UI, analysis, formatting, metadata, entitlement, architecture, + size, and independent-review checks in `tasks/todo.md`. +- Use an isolated SSH account with a restricted writable directory. Verify the + password login, fresh-install public-key authorization, switch to SSH key + authentication, voice permission and transcription flow, copied-image upload, + and cleanup steps without storing credentials in this repository. +- If the review endpoint uses a TLS-terminated TCP relay, enable the per-host + TLS tunnel option and test from a physical device outside any private VPN path. +- Select an unused build number and change both targets together only after user + approval. +- Get separate user approval for each archive, upload, App Review reply, metadata + change, submission cancellation, and resubmission action. +- Website publication is separate from App Store work. A push to `main` that + changes `website/**` triggers `.github/workflows/deploy-site.yml` and publishes + the site. Get explicit approval before that push. + +### Export compliance and App Privacy publication + +The August 28, 2026 source audit found that source evidence alone did not +support the earlier `usesNonExemptEncryption: false` build value: + +- Citadel 0.12.0 is linked into the app and keyboard extension. +- KeyJawn does not enable Citadel's optional `SSHAlgorithms.all` set. Its live + terminal and SFTP connections use the SwiftNIO SSH defaults, which use + CryptoKit on Apple platforms. +- Citadel still contains BoringSSL-backed AES-128-CTR, Diffie-Hellman group 14, + and RSA implementations. The optimized Release simulator app and keyboard + extension binaries contain those symbols after linking. +- The algorithms and the SSH protocol are published industry standards. The + source audit found no proprietary or unpublished cryptography. + +The final signed archive, Apple territory selection, and current Apple guidance +now support `usesNonExemptEncryption: false` for build 9. The final plists keep +`ITSAppUsesNonExemptEncryption` absent, so the answer was saved on the processed +build instead. App Store Connect accepted the value and a read-back confirmed +it. This is the recorded App Store Connect export-compliance determination. No +`ITSEncryptionExportComplianceCode` or encryption declaration is attached. + +Apple's current documentation says that an app with industry-standard +encryption not supplied by the operating system needs a French encryption +declaration only if the app is distributed in France. Build 9 is unavailable in +France and available in the other 174 current territories. Apple processed the +uploaded build as valid, and the processed bundle matches the expected app and +extension identifiers, arm64 architecture, iOS 17.0 minimum, distribution +entitlements, App Group, and Keychain group. The App Store Connect encryption +value is false. Confirm the separate US Bureau of Industry and Security +classification or reporting duty with qualified counsel or the Apple +export-compliance team before release. + +Sources: [Apple export compliance overview](https://developer.apple.com/help/app-store-connect/manage-app-information/overview-of-export-compliance), +[Apple encryption documentation table](https://developer.apple.com/help/app-store-connect/reference/app-information/export-compliance-documentation-for-encryption), +[Apple property-list key reference](https://developer.apple.com/documentation/bundleresources/information-property-list/itsappusesnonexemptencryption), +and [BIS mass-market guidance](https://www.bis.gov/learn-support/encryption-controls/mass-market). + +Inspect the current App Privacy answers in the App Store Connect web interface. +Reading the answers does not publish a change. Do not edit the answers or select +Publish without explicit user approval at action time. diff --git a/docs/ios-development-research.md b/docs/ios-development-research.md index 41ead33..1ff5214 100644 --- a/docs/ios-development-research.md +++ b/docs/ios-development-research.md @@ -1,4 +1,10 @@ -# KeyJawn iOS Development Research +# KeyJawn iOS development research + +> **Status:** Superseded historical research from February 2026. Do not use this +> file for current iOS behavior, App Store status, metadata, or release actions. +> See [`ios-app-review.md`](ios-app-review.md), +> [`app-store-v1.0-metadata.md`](app-store-v1.0-metadata.md), and +> [`../tasks/todo.md`](../tasks/todo.md). Comprehensive analysis of every viable (and non-viable) approach to bringing KeyJawn to iOS. Research conducted February 2026. diff --git a/docs/plans/2026-02-17-ios-app-design.md b/docs/plans/2026-02-17-ios-app-design.md index c7b69d8..19ee1d1 100644 --- a/docs/plans/2026-02-17-ios-app-design.md +++ b/docs/plans/2026-02-17-ios-app-design.md @@ -1,5 +1,11 @@ # KeyJawn iOS app design +> **Status:** Superseded historical design. The shipped implementation and +> current guideline 2.5.2 remediation have changed this plan. Do not use it for +> current behavior, App Store metadata, or release actions. See +> [`../ios-app-review.md`](../ios-app-review.md) and +> [`../app-store-v1.0-metadata.md`](../app-store-v1.0-metadata.md). + Hybrid iOS app: SSH terminal with built-in KeyJawn keyboard + system-wide keyboard extension, shipped as a single App Store download. ## Decisions diff --git a/docs/plans/2026-02-20-app-review-3.2.2-fix-design.md b/docs/plans/2026-02-20-app-review-3.2.2-fix-design.md index dd10bfb..ff056a6 100644 --- a/docs/plans/2026-02-20-app-review-3.2.2-fix-design.md +++ b/docs/plans/2026-02-20-app-review-3.2.2-fix-design.md @@ -1,5 +1,10 @@ # App Review 3.2.2 fix design +> **Status:** Superseded historical review plan. The current rejection is under +> guideline 2.5.2 and identifies review build 2. Do not send the response or run +> the release actions in this file. See [`../ios-app-review.md`](../ios-app-review.md) +> and [`../app-store-v1.0-metadata.md`](../app-store-v1.0-metadata.md). + **Date:** 2026-02-20 **Submission ID:** 83b2805c-650c-4bf6-91ff-0c6339f36324 **Guideline:** 3.2.2 — Business > Other Business Model Issues > Unacceptable diff --git a/docs/plans/2026-02-20-app-review-3.2.2-fix-implementation.md b/docs/plans/2026-02-20-app-review-3.2.2-fix-implementation.md index c071d01..e3d3f68 100644 --- a/docs/plans/2026-02-20-app-review-3.2.2-fix-implementation.md +++ b/docs/plans/2026-02-20-app-review-3.2.2-fix-implementation.md @@ -1,5 +1,13 @@ # App Review 3.2.2 fix implementation plan +> **Status:** Superseded historical implementation plan. Do not send its reply, +> change metadata, archive, upload, or resubmit from this file. The active +> rejection is under guideline 2.5.2. See +> [`../ios-app-review.md`](../ios-app-review.md) and +> [`../app-store-v1.0-metadata.md`](../app-store-v1.0-metadata.md). +> The old website screenshot paths below no longer exist. Current release +> assets are indexed in `ios/AppStore/Screenshots/README.md`. + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Send a reply to Apple App Review explaining what KeyJawn is, and land code/metadata changes that remove the 3.2.2 ambiguity so a clean build is ready regardless of appeal outcome. diff --git a/docs/release-style-guide.md b/docs/release-style-guide.md index 76a3830..cbb2fb9 100644 --- a/docs/release-style-guide.md +++ b/docs/release-style-guide.md @@ -1,38 +1,153 @@ # Release notes style guide -Format for GitHub release notes on the keyjawn repository. +Use these templates only after the listed state is verified. A source build, an +archive, an upload, and a store release are different states. -## Template +## Required status block + +Add this block to each release plan or handoff: + +```markdown +## Distribution state + +- Android GitHub release: [not started, uploaded, or verified at URL] +- Android website delivery: [not started, uploaded, or verified] +- Google Play: [not started, testing track, submitted, or available] +- iOS source build: [build number] +- iOS archive: [not created or verified archive path and time] +- TestFlight upload: [not uploaded, processing, or verified available] +- App Store submission: [not submitted, in review, rejected with guideline, or available] +- External actions approved by: [name and exact approved actions] +``` + +Do not use “released,” “uploaded,” “submitted,” “processing,” “approved,” or +“available” until a read-back from the applicable service confirms that state. + +## Android GitHub release template ```markdown ![KeyJawn branding graphic](https://i.imgur.com/c6z2Gl0.jpeg) [![Build](https://github.com/jamditis/keyjawn/actions/workflows/build.yml/badge.svg)](https://github.com/jamditis/keyjawn/actions/workflows/build.yml) -## What's new / What's fixed +## What changed + +### [Android feature or fix] -### Feature or fix name -Description paragraph. What it does, why it matters. Keep it concrete. +[State what changed, who it helps, and any limit.] -### Second feature or fix name -Same format. +## Verification + +- `./gradlew testFullDebugUnitTest`: [result] +- `./gradlew testLiteDebugUnitTest`: [result] +- Release artifact check: [result] ## Downloads | File | Description | |------|-------------| -| `app-lite-release.apk` | Free version (GitHub) | -| Full version | Available via [keyjawn.amditis.tech](https://keyjawn.amditis.tech/pricing) ($4, includes all features) | +| `app-lite-release.apk` | Free Android version from GitHub | +| Android full version | Available through [keyjawn.amditis.tech](https://keyjawn.amditis.tech/pricing) after the website artifact is verified | + +Full changelog: https://github.com/jamditis/keyjawn/compare/v[previous]...v[current] +``` + +## TestFlight notes template + +```text +[State the iOS change in one sentence.] + +Test: +- Remote SSH connection and terminal input/output. +- [Specific changed flow.] +- Companion keyboard in a text field that supports third-party keyboards. + +Limits: +- Commands execute on the configured remote server, not on iOS. +- The system keyboard appears in secure and phone-pad fields, and apps can block third-party keyboards. +- [Other limit that applies to this build.] +``` + +## App Store version notes template -**Full changelog**: https://github.com/jamditis/keyjawn/compare/vPREV...vCURR +```text +This update [states the user-visible change]. + +- [Change one.] +- [Change two.] +- [Fix or limit, if needed.] ``` -## Rules +Do not put reviewer arguments, test credentials, internal build state, or +unverified future features in public version notes. Use +[`app-store-v1.0-metadata.md`](app-store-v1.0-metadata.md) for the App Review +draft. + +## App Review handoff template + +```markdown +## App Review state + +- Guideline: [guideline] +- Reviewed build: [build number] +- Source build: [build number] +- Archive: [not created or verified path] +- Upload: [not uploaded or verified App Store Connect state] +- Selected submission build: [none or build number] +- Reviewer response: [draft only or sent with verified time] +- Metadata changes: [none or exact verified fields] +- Resubmission: [not done or verified state] +- Remaining checks: [list] +- Required approvals: [list each external action separately] +``` + +## Current iOS remediation state + +- Apple rejected review build 2 under guideline 2.5.2. +- Source build 9 exists. +- A fresh signed build 9 archive was created and verified from the corrected + source on August 31, 2026. The exact archive was uploaded, and Apple processed + build 9 as valid. Build 9 is selected for version 1.0, manual release is + enabled, and the submission is waiting for review. The app is not approved + and not publicly released. + +## Current Android distribution state + +- KeyJawn Lite 1.3.0 is active on the internal and closed Google Play test + tracks. +- Open testing and production are inactive. +- Google Play app signing is active. +- 7 of the required 12 closed-test users are opted in. Google also requires at + least 14 days with 12 opted-in testers before production access. +- Source version 1.4.0, version code 10, targets API 36. A local bundle made + with a disposable verification key confirmed this manifest. It is not an + upload artifact. No Android bundle was uploaded or promoted in this + remediation. +- Creating a version tag triggers an internal-track upload. Get explicit user + approval before creating a tag or promoting a build to another Play track. + +### Internal-to-closed promotion procedure + +1. Confirm the selected internal artifact, version code, version name, and + release notes in Play Console. +2. If a new artifact is needed, get explicit approval before an upload or + version tag. +3. Get separate explicit approval to promote the selected release to the + existing closed alpha track. +4. After promotion, read back the selected release, track state, and tester + list from Play Console. +5. Keep at least 12 testers opted in continuously for 14 days. +6. Get separate approval before applying for production access. + +## Writing rules -- Start with branding image and CI badge -- Use `## What's new` for feature releases, `## What's fixed` for bug fixes, both if mixed -- Each feature/fix gets an `### H3` with a short name -- Description is 1-2 paragraphs, plain language, no marketing speak -- End with downloads table and full changelog link -- No emojis -- Release title format: `vX.Y.Z - Short description in sentence case` +- Use sentence case. +- State the platform for each platform-specific feature. +- Use plain language and one or two short paragraphs per change. +- Describe remote SSH execution as remote. Do not imply local iOS command + execution or device-file access. +- For the iOS keyboard, say “where third-party keyboards are supported.” Do not + say “any app,” “every app,” or “system-wide.” +- Label Android-only voice input, SCP upload, themes, and gesture features. +- Do not use emojis, hype, or future distribution claims. +- Format a GitHub release title as `vX.Y.Z - Short sentence-case description`. diff --git a/ios/AppStore/Screenshots/README.md b/ios/AppStore/Screenshots/README.md new file mode 100644 index 0000000..60aea57 --- /dev/null +++ b/ios/AppStore/Screenshots/README.md @@ -0,0 +1,21 @@ +# App Store screenshots + +These files are the current English App Store screenshot set for version 1.0.0. +They come from `KeyJawnUITests` on iOS 17.5 simulators. Do not edit or crop them. + +## Device sets + +- `en-US/iphone-6.9`: 1290 x 2796 pixels from iPhone 15 Pro Max +- `en-US/ipad-13`: 2064 x 2752 pixels from iPad Pro 13-inch (M4) + +Each device set contains the same five screens: + +1. `01-remote-boundary.jpg`: states that commands run on a remote SSH server +2. `02-full-access.jpg`: explains that basic typing works without Full Access +3. `03-remote-hosts.jpg`: shows the empty remote-host list +4. `04-settings.jpg`: shows keyboard and SSH settings +5. `05-companion-keyboard.jpg`: shows KeyJawn as a system keyboard in a blank host form + +The keyboard screenshot uses Full Access off. The tests restore the original simulator keyboard state after capture. No screenshot contains credentials, keys, terminal history, clipboard data, or a real host name. + +Before upload, run `sips -g pixelWidth -g pixelHeight -g hasAlpha` on every file. Confirm the listed dimensions and `hasAlpha: no`. Apple upload is a separate release action and needs explicit approval. diff --git a/ios/AppStore/Screenshots/en-US/ipad-13/01-remote-boundary.jpg b/ios/AppStore/Screenshots/en-US/ipad-13/01-remote-boundary.jpg new file mode 100644 index 0000000..1b59c54 Binary files /dev/null and b/ios/AppStore/Screenshots/en-US/ipad-13/01-remote-boundary.jpg differ diff --git a/ios/AppStore/Screenshots/en-US/ipad-13/02-full-access.jpg b/ios/AppStore/Screenshots/en-US/ipad-13/02-full-access.jpg new file mode 100644 index 0000000..4ed6da3 Binary files /dev/null and b/ios/AppStore/Screenshots/en-US/ipad-13/02-full-access.jpg differ diff --git a/ios/AppStore/Screenshots/en-US/ipad-13/03-remote-hosts.jpg b/ios/AppStore/Screenshots/en-US/ipad-13/03-remote-hosts.jpg new file mode 100644 index 0000000..7f50936 Binary files /dev/null and b/ios/AppStore/Screenshots/en-US/ipad-13/03-remote-hosts.jpg differ diff --git a/ios/AppStore/Screenshots/en-US/ipad-13/04-settings.jpg b/ios/AppStore/Screenshots/en-US/ipad-13/04-settings.jpg new file mode 100644 index 0000000..223533b Binary files /dev/null and b/ios/AppStore/Screenshots/en-US/ipad-13/04-settings.jpg differ diff --git a/ios/AppStore/Screenshots/en-US/ipad-13/05-companion-keyboard.jpg b/ios/AppStore/Screenshots/en-US/ipad-13/05-companion-keyboard.jpg new file mode 100644 index 0000000..a968329 Binary files /dev/null and b/ios/AppStore/Screenshots/en-US/ipad-13/05-companion-keyboard.jpg differ diff --git a/ios/AppStore/Screenshots/en-US/iphone-6.9/01-remote-boundary.jpg b/ios/AppStore/Screenshots/en-US/iphone-6.9/01-remote-boundary.jpg new file mode 100644 index 0000000..5f6ccad Binary files /dev/null and b/ios/AppStore/Screenshots/en-US/iphone-6.9/01-remote-boundary.jpg differ diff --git a/ios/AppStore/Screenshots/en-US/iphone-6.9/02-full-access.jpg b/ios/AppStore/Screenshots/en-US/iphone-6.9/02-full-access.jpg new file mode 100644 index 0000000..a686b55 Binary files /dev/null and b/ios/AppStore/Screenshots/en-US/iphone-6.9/02-full-access.jpg differ diff --git a/ios/AppStore/Screenshots/en-US/iphone-6.9/03-remote-hosts.jpg b/ios/AppStore/Screenshots/en-US/iphone-6.9/03-remote-hosts.jpg new file mode 100644 index 0000000..a0838d3 Binary files /dev/null and b/ios/AppStore/Screenshots/en-US/iphone-6.9/03-remote-hosts.jpg differ diff --git a/ios/AppStore/Screenshots/en-US/iphone-6.9/04-settings.jpg b/ios/AppStore/Screenshots/en-US/iphone-6.9/04-settings.jpg new file mode 100644 index 0000000..c8f0dd1 Binary files /dev/null and b/ios/AppStore/Screenshots/en-US/iphone-6.9/04-settings.jpg differ diff --git a/ios/AppStore/Screenshots/en-US/iphone-6.9/05-companion-keyboard.jpg b/ios/AppStore/Screenshots/en-US/iphone-6.9/05-companion-keyboard.jpg new file mode 100644 index 0000000..a036350 Binary files /dev/null and b/ios/AppStore/Screenshots/en-US/iphone-6.9/05-companion-keyboard.jpg differ diff --git a/ios/KeyJawn.xcodeproj/project.pbxproj b/ios/KeyJawn.xcodeproj/project.pbxproj index 97883ea..4a494f3 100644 --- a/ios/KeyJawn.xcodeproj/project.pbxproj +++ b/ios/KeyJawn.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 04C7D266E9A0441A2C5F80B4 /* AppReviewBoundaryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA369158F5C3FFC332E9A85 /* AppReviewBoundaryTests.swift */; }; 08A795D28CCACD6079370712 /* CitadelSCPUploader.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C051D02F5B8C10F3E75D7A /* CitadelSCPUploader.swift */; }; 112B82237850CDFD6CE0A92C /* SlashCommandStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD05F7B8B824B359DEE870C9 /* SlashCommandStoreTests.swift */; }; 12AE43CD002393743BACEB75 /* KeyboardThemeContrastTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAA8A921E20FE294CCFC815A /* KeyboardThemeContrastTests.swift */; }; @@ -17,29 +18,34 @@ 1F3BC0FB1DEE09CF9586840D /* HostTerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7588DD4C7951CF1F99C3A433 /* HostTerminalView.swift */; }; 1F6BA06DD660C887B3E1F79C /* KeyJawnApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD9AC295E87CCA2D770BDB6A /* KeyJawnApp.swift */; }; 227F4E52BB865C7B00BA9167 /* KeyboardMetricsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CE214E0B998D01BC698998B /* KeyboardMetricsTests.swift */; }; + 2971479D4C1A303EF5B87EA4 /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9464A43A61E9EB8B41A987 /* AppTheme.swift */; }; 306C46342B0B52F882443953 /* AppGroupHostStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88516F15428436E247E65FD4 /* AppGroupHostStore.swift */; }; 30FBF6554FC7A6F5548299C1 /* KeyboardViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B581E797ADEF1572390A6096 /* KeyboardViewController.swift */; }; 32EB71B5DE76A94E371D71A0 /* SSHSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23A4B394C56787E8E05B58A1 /* SSHSession.swift */; }; 348C2A63B6E68B94EE380311 /* InputViewAudioFeedback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97D63ED3E15ECBC88F45E3B6 /* InputViewAudioFeedback.swift */; }; 35BA636F86655A1E133B3BB2 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 98623C822345E60C48D7464D /* Assets.xcassets */; }; 3694F662CD2D10BFC8388AF2 /* TerminalInputMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64762D93ADF3F2AB82F1A548 /* TerminalInputMappingTests.swift */; }; + 3BCE8BBE61B53727D5544697 /* NIOTransportServices in Frameworks */ = {isa = PBXBuildFile; productRef = 1BA43024421B2FA71AB6DACD /* NIOTransportServices */; }; 3E7E682B7731F46E6D232280 /* ClipboardHistoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38264316C6A0140F8FEB1667 /* ClipboardHistoryTests.swift */; }; 5A4CC73980A76F9DB9E5A5E2 /* TerminalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF6FA031277F874D263EF7AD /* TerminalViewController.swift */; }; 5A51BC905F126948A48544C7 /* KeyboardPrefsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 396D44A07DE95A000C7627D0 /* KeyboardPrefsTests.swift */; }; 5D3FA20C7AADA0E3754284D5 /* CtrlStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02EAAA4D51DA2E825C9B5BA /* CtrlStateTests.swift */; }; 5DF5361C7BE43FB5D0F34CA5 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62CD2B5A99C09A3BD18B1B2C /* ContentView.swift */; }; 5E5AAF93DC09AC4305BDA08D /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3ABDD7F74E5CC85881A5948 /* OnboardingView.swift */; }; + 633182758FD1C3F4CEAC0039 /* SSHErrorPresentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D2E96379EE12BD39690AB56 /* SSHErrorPresentationTests.swift */; }; 6350BC0215D4AF763D7B6A6B /* Citadel in Frameworks */ = {isa = PBXBuildFile; productRef = CB9AD386707E3751FAD823A1 /* Citadel */; }; 64F0A7BFAD402AB5064BA7A1 /* CitadelSCPUploader.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C051D02F5B8C10F3E75D7A /* CitadelSCPUploader.swift */; }; 67B39BE35B75861A3813BF5C /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEF300C5B755C35A8B51FED3 /* SettingsView.swift */; }; 68B2C30E8CD52FC6DE13253C /* AltKeyMappingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5835AD51EEE0C920DE898BD /* AltKeyMappingsTests.swift */; }; 68B8A22CEFDB19DE99CA2EB5 /* HostListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 301482A615F90B93947563BC /* HostListView.swift */; }; - 76503EE687E0F88885DD27DE /* NIOSSH in Frameworks */ = {isa = PBXBuildFile; productRef = 974D61CF6F7ADA4824D53DC7 /* NIOSSH */; }; + 69648FB62CCF5F0909CD341B /* AppleNetworkSSHTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2FDC804223EF39F1C89C9E3 /* AppleNetworkSSHTransport.swift */; }; + 76503EE687E0F88885DD27DE /* NIOTransportServices in Frameworks */ = {isa = PBXBuildFile; productRef = B9F4B11DBAC437963B047073 /* NIOTransportServices */; }; 7E2C29A442AE9E2630A2F301 /* HostStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC5405F0BD516F74C8E0EED5 /* HostStore.swift */; }; 913F17D07A48356C667DF51A /* TerminalVoiceInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 458667FC8591E01311ADA8AF /* TerminalVoiceInput.swift */; }; 9DA97D841DD222F4BC99CFC9 /* KeyJawnKit in Frameworks */ = {isa = PBXBuildFile; productRef = 1BD7D5FD852EEF611DFEF3BA /* KeyJawnKit */; }; A58E56EF6474C8DBC8512A2F /* KeyboardStressTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BD88603C1C49C4DCF6E4364 /* KeyboardStressTests.swift */; }; A793BD0FFA4FDCD6DA278117 /* WordBoundaryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 686608D07F38BF2952BC2A05 /* WordBoundaryTests.swift */; }; + A8753EF37E06D35BB30366F5 /* AppleNetworkSSHTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2FDC804223EF39F1C89C9E3 /* AppleNetworkSSHTransport.swift */; }; A9DB22FF0F4C815305CC6106 /* KeyJawnKit in Frameworks */ = {isa = PBXBuildFile; productRef = D3233B073CCE7B0FEFB818EA /* KeyJawnKit */; }; AA523563B70682B4341E113C /* SSHKeysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CC90B529187AE9DAFC1CDE6 /* SSHKeysView.swift */; }; B6C2D23E9D71B57A14E105FD /* KeyboardIMETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A19791E47A6CF77CB424FCAC /* KeyboardIMETests.swift */; }; @@ -50,7 +56,7 @@ BF99261856285D2DA591AEB2 /* KeyJawnKeyboard.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 22D494658DBEB0D051FF4A8C /* KeyJawnKeyboard.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; C01DB065ADBBD129FDFA832F /* SlashCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9DF2807E8DD4CB2A02999BA /* SlashCommandTests.swift */; }; C2CB34B81FFC8B56D356D8AF /* HostConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B940C548C192782A1073E16 /* HostConfigTests.swift */; }; - C2F8D707F7F9F58DA50E2A7F /* NIOPosix in Frameworks */ = {isa = PBXBuildFile; productRef = 2C94B23FBDB89626C8A0294C /* NIOPosix */; }; + C2F8D707F7F9F58DA50E2A7F /* NIOSSH in Frameworks */ = {isa = PBXBuildFile; productRef = 974D61CF6F7ADA4824D53DC7 /* NIOSSH */; }; C5D73FD296E1B9A8CD97626C /* SSHKeyStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE62DD6DE973B1FB26C0314 /* SSHKeyStore.swift */; }; CA738FCA57272508A304C827 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = DBC5B9DCB9FBC1D3E054CE8F /* SwiftTerm */; }; CCDB3D09BF10D8B0C6C4B9BD /* ExtraRowPresetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54003BE0AD4A0942B2C7D0F1 /* ExtraRowPresetTests.swift */; }; @@ -59,7 +65,10 @@ DC086B3EB381E69972DDC0AA /* HardwareKeyMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F5FD863B6B104B7A7FBE477 /* HardwareKeyMappingTests.swift */; }; DE63F87E7794F4FF03DA7A43 /* ANSISequenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE81ADA79A468E5CF872E5B1 /* ANSISequenceTests.swift */; }; DF7E0C0E55646C2A8443B118 /* KeyboardInsertPathTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7B5B0254BBE72FA1A9ADA0 /* KeyboardInsertPathTests.swift */; }; + E0D62D7EA9A218B684C9A0FF /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7FD491A7FEBB1393E4A1B428 /* PrivacyInfo.xcprivacy */; }; + E23D75E9A55B1DC1E145E732 /* SSHLiveIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9CEFF845DBA937C41015D14 /* SSHLiveIntegrationTests.swift */; }; E488DF8C0300EA5A5A26626B /* SlashScreenshotRenderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 524D3943A19BA98402D9549F /* SlashScreenshotRenderTests.swift */; }; + E807E472C054AD4CFB288748 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7FD491A7FEBB1393E4A1B428 /* PrivacyInfo.xcprivacy */; }; EAD2BFA42A42418E10696035 /* KeyboardLayersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98604D5BA08246970836FD92 /* KeyboardLayersTests.swift */; }; EB892014C8DC6C2A3258F241 /* PasswordPromptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6274AD171FD2D143D7DFDAC /* PasswordPromptView.swift */; }; ED013FF5D11FE489D6798508 /* HostEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5418E5BFCD5CA8FE0DDDAC5 /* HostEditView.swift */; }; @@ -120,6 +129,7 @@ 3AF4D90B05932E700713A941 /* ExtensionHonestyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionHonestyTests.swift; sourceTree = ""; }; 3CE214E0B998D01BC698998B /* KeyboardMetricsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardMetricsTests.swift; sourceTree = ""; }; 458667FC8591E01311ADA8AF /* TerminalVoiceInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalVoiceInput.swift; sourceTree = ""; }; + 4E9464A43A61E9EB8B41A987 /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = ""; }; 524D3943A19BA98402D9549F /* SlashScreenshotRenderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlashScreenshotRenderTests.swift; sourceTree = ""; }; 54003BE0AD4A0942B2C7D0F1 /* ExtraRowPresetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtraRowPresetTests.swift; sourceTree = ""; }; 62CD2B5A99C09A3BD18B1B2C /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -127,7 +137,9 @@ 665662BFA6DECCD61154BEE8 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 686608D07F38BF2952BC2A05 /* WordBoundaryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordBoundaryTests.swift; sourceTree = ""; }; 7588DD4C7951CF1F99C3A433 /* HostTerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostTerminalView.swift; sourceTree = ""; }; + 7D2E96379EE12BD39690AB56 /* SSHErrorPresentationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHErrorPresentationTests.swift; sourceTree = ""; }; 7F5FD863B6B104B7A7FBE477 /* HardwareKeyMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HardwareKeyMappingTests.swift; sourceTree = ""; }; + 7FD491A7FEBB1393E4A1B428 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 838BAAD1067A5FE9A2DDE1E4 /* VoiceSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceSessionTests.swift; sourceTree = ""; }; 88516F15428436E247E65FD4 /* AppGroupHostStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroupHostStore.swift; sourceTree = ""; }; 8BD88603C1C49C4DCF6E4364 /* KeyboardStressTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardStressTests.swift; sourceTree = ""; }; @@ -136,8 +148,11 @@ 98623C822345E60C48D7464D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 99A67662436F2C1180E1376F /* KeyJawnKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = KeyJawnKit; path = KeyJawnKit; sourceTree = SOURCE_ROOT; }; 9B940C548C192782A1073E16 /* HostConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostConfigTests.swift; sourceTree = ""; }; + 9FA369158F5C3FFC332E9A85 /* AppReviewBoundaryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppReviewBoundaryTests.swift; sourceTree = ""; }; A19791E47A6CF77CB424FCAC /* KeyboardIMETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardIMETests.swift; sourceTree = ""; }; + A2FDC804223EF39F1C89C9E3 /* AppleNetworkSSHTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleNetworkSSHTransport.swift; sourceTree = ""; }; A6274AD171FD2D143D7DFDAC /* PasswordPromptView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordPromptView.swift; sourceTree = ""; }; + A9CEFF845DBA937C41015D14 /* SSHLiveIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHLiveIntegrationTests.swift; sourceTree = ""; }; AD9AC295E87CCA2D770BDB6A /* KeyJawnApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyJawnApp.swift; sourceTree = ""; }; AEF300C5B755C35A8B51FED3 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; B5418E5BFCD5CA8FE0DDDAC5 /* HostEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostEditView.swift; sourceTree = ""; }; @@ -175,8 +190,8 @@ CA738FCA57272508A304C827 /* SwiftTerm in Frameworks */, 6350BC0215D4AF763D7B6A6B /* Citadel in Frameworks */, B958AD522581630C2F77C218 /* NIOCore in Frameworks */, - C2F8D707F7F9F58DA50E2A7F /* NIOPosix in Frameworks */, - 76503EE687E0F88885DD27DE /* NIOSSH in Frameworks */, + C2F8D707F7F9F58DA50E2A7F /* NIOSSH in Frameworks */, + 76503EE687E0F88885DD27DE /* NIOTransportServices in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -188,6 +203,7 @@ D4959C7C420241BE04B8CD59 /* Citadel in Frameworks */, BB7A5E0F892D8EA9821654B3 /* NIOCore in Frameworks */, EF86DC91B3F6A441505BFFF0 /* NIOSSH in Frameworks */, + 3BCE8BBE61B53727D5544697 /* NIOTransportServices in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -219,6 +235,7 @@ children = ( D5835AD51EEE0C920DE898BD /* AltKeyMappingsTests.swift */, DE81ADA79A468E5CF872E5B1 /* ANSISequenceTests.swift */, + 9FA369158F5C3FFC332E9A85 /* AppReviewBoundaryTests.swift */, 38264316C6A0140F8FEB1667 /* ClipboardHistoryTests.swift */, D02EAAA4D51DA2E825C9B5BA /* CtrlStateTests.swift */, 3AF4D90B05932E700713A941 /* ExtensionHonestyTests.swift */, @@ -235,6 +252,8 @@ DD05F7B8B824B359DEE870C9 /* SlashCommandStoreTests.swift */, B9DF2807E8DD4CB2A02999BA /* SlashCommandTests.swift */, 524D3943A19BA98402D9549F /* SlashScreenshotRenderTests.swift */, + 7D2E96379EE12BD39690AB56 /* SSHErrorPresentationTests.swift */, + A9CEFF845DBA937C41015D14 /* SSHLiveIntegrationTests.swift */, 64762D93ADF3F2AB82F1A548 /* TerminalInputMappingTests.swift */, 838BAAD1067A5FE9A2DDE1E4 /* VoiceSessionTests.swift */, 686608D07F38BF2952BC2A05 /* WordBoundaryTests.swift */, @@ -311,7 +330,9 @@ BFFD31A5D7E5989EFA817A79 /* Shared */ = { isa = PBXGroup; children = ( + A2FDC804223EF39F1C89C9E3 /* AppleNetworkSSHTransport.swift */, D9C051D02F5B8C10F3E75D7A /* CitadelSCPUploader.swift */, + 7FD491A7FEBB1393E4A1B428 /* PrivacyInfo.xcprivacy */, ); path = Shared; sourceTree = ""; @@ -332,6 +353,7 @@ D6EA56F0196ABB9D8094E703 /* App */ = { isa = PBXGroup; children = ( + 4E9464A43A61E9EB8B41A987 /* AppTheme.swift */, 62CD2B5A99C09A3BD18B1B2C /* ContentView.swift */, AD9AC295E87CCA2D770BDB6A /* KeyJawnApp.swift */, C3ABDD7F74E5CC85881A5948 /* OnboardingView.swift */, @@ -408,8 +430,8 @@ DBC5B9DCB9FBC1D3E054CE8F /* SwiftTerm */, CB9AD386707E3751FAD823A1 /* Citadel */, 9319CFF9722F6941701039BF /* NIOCore */, - 2C94B23FBDB89626C8A0294C /* NIOPosix */, 974D61CF6F7ADA4824D53DC7 /* NIOSSH */, + B9F4B11DBAC437963B047073 /* NIOTransportServices */, ); productName = KeyJawn; productReference = 1455FBB90189E98F89DA75E3 /* KeyJawn.app */; @@ -420,6 +442,7 @@ buildConfigurationList = 583BB0090DF66C4B0088B727 /* Build configuration list for PBXNativeTarget "KeyJawnKeyboard" */; buildPhases = ( 237E92B592B1ABCED91EE6C6 /* Sources */, + 083F10F929BACB6F87EF0A75 /* Resources */, 7D5E467AA933C5A8635C1105 /* Frameworks */, ); buildRules = ( @@ -432,6 +455,7 @@ 060BD31A2DD85CF9C0F6FE6F /* Citadel */, C5D92697896E36FD6161FB69 /* NIOCore */, E6BD72243EA2001DD46465E8 /* NIOSSH */, + 1BA43024421B2FA71AB6DACD /* NIOTransportServices */, ); productName = KeyJawnKeyboard; productReference = 22D494658DBEB0D051FF4A8C /* KeyJawnKeyboard.appex */; @@ -477,6 +501,7 @@ 4435EB56219D1AEF4C3147DE /* XCRemoteSwiftPackageReference "Citadel" */, E40D541DF4CADBAB4CD9CCCB /* XCRemoteSwiftPackageReference "swift-nio" */, 1E63AA3983889C46E6005E9E /* XCRemoteSwiftPackageReference "swift-nio-ssh" */, + 38E5254CE152976536ED11B9 /* XCRemoteSwiftPackageReference "swift-nio-transport-services" */, 05B7D614E2AAC85421DE92CC /* XCRemoteSwiftPackageReference "SwiftTerm" */, 110CA06B401C7ED704453078 /* XCLocalSwiftPackageReference "KeyJawnKit" */, ); @@ -493,11 +518,20 @@ /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 083F10F929BACB6F87EF0A75 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E0D62D7EA9A218B684C9A0FF /* PrivacyInfo.xcprivacy in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; EB0779E2B1771FDE9FDDBEDC /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( 35BA636F86655A1E133B3BB2 /* Assets.xcassets in Resources */, + E807E472C054AD4CFB288748 /* PrivacyInfo.xcprivacy in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -510,6 +544,7 @@ files = ( DE63F87E7794F4FF03DA7A43 /* ANSISequenceTests.swift in Sources */, 68B2C30E8CD52FC6DE13253C /* AltKeyMappingsTests.swift in Sources */, + 04C7D266E9A0441A2C5F80B4 /* AppReviewBoundaryTests.swift in Sources */, 3E7E682B7731F46E6D232280 /* ClipboardHistoryTests.swift in Sources */, 5D3FA20C7AADA0E3754284D5 /* CtrlStateTests.swift in Sources */, 1537E06440732A988EE7463F /* ExtensionHonestyTests.swift in Sources */, @@ -523,6 +558,8 @@ A58E56EF6474C8DBC8512A2F /* KeyboardStressTests.swift in Sources */, 12AE43CD002393743BACEB75 /* KeyboardThemeContrastTests.swift in Sources */, 15533E56B3ECB76AAEE83116 /* OnboardingTests.swift in Sources */, + 633182758FD1C3F4CEAC0039 /* SSHErrorPresentationTests.swift in Sources */, + E23D75E9A55B1DC1E145E732 /* SSHLiveIntegrationTests.swift in Sources */, 112B82237850CDFD6CE0A92C /* SlashCommandStoreTests.swift in Sources */, C01DB065ADBBD129FDFA832F /* SlashCommandTests.swift in Sources */, E488DF8C0300EA5A5A26626B /* SlashScreenshotRenderTests.swift in Sources */, @@ -537,6 +574,7 @@ buildActionMask = 2147483647; files = ( 306C46342B0B52F882443953 /* AppGroupHostStore.swift in Sources */, + A8753EF37E06D35BB30366F5 /* AppleNetworkSSHTransport.swift in Sources */, 64F0A7BFAD402AB5064BA7A1 /* CitadelSCPUploader.swift in Sources */, 348C2A63B6E68B94EE380311 /* InputViewAudioFeedback.swift in Sources */, 30FBF6554FC7A6F5548299C1 /* KeyboardViewController.swift in Sources */, @@ -557,6 +595,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 2971479D4C1A303EF5B87EA4 /* AppTheme.swift in Sources */, + 69648FB62CCF5F0909CD341B /* AppleNetworkSSHTransport.swift in Sources */, 08A795D28CCACD6079370712 /* CitadelSCPUploader.swift in Sources */, 5DF5361C7BE43FB5D0F34CA5 /* ContentView.swift in Sources */, ED013FF5D11FE489D6798508 /* HostEditView.swift in Sources */, @@ -784,7 +824,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = Configuration/KeyJawnKeyboard.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 8; + CURRENT_PROJECT_VERSION = 9; DEVELOPMENT_TEAM = 5624SD289G; INFOPLIST_FILE = KeyJawnKeyboard/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 17.0; @@ -808,7 +848,7 @@ CODE_SIGN_ENTITLEMENTS = Configuration/KeyJawnKeyboard.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 8; + CURRENT_PROJECT_VERSION = 9; DEVELOPMENT_TEAM = 5624SD289G; INFOPLIST_FILE = KeyJawnKeyboard/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 17.0; @@ -852,11 +892,10 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Configuration/KeyJawn.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 8; + CURRENT_PROJECT_VERSION = 9; DEVELOPMENT_TEAM = 5624SD289G; INFOPLIST_FILE = KeyJawn/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 17.0; @@ -877,11 +916,10 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Configuration/KeyJawn.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 8; + CURRENT_PROJECT_VERSION = 9; DEVELOPMENT_TEAM = 5624SD289G; INFOPLIST_FILE = KeyJawn/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 17.0; @@ -973,6 +1011,14 @@ minimumVersion = 0.3.0; }; }; + 38E5254CE152976536ED11B9 /* XCRemoteSwiftPackageReference "swift-nio-transport-services" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-nio-transport-services.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.26.0; + }; + }; 4435EB56219D1AEF4C3147DE /* XCRemoteSwiftPackageReference "Citadel" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/orlandos-nl/Citadel"; @@ -997,14 +1043,14 @@ package = 4435EB56219D1AEF4C3147DE /* XCRemoteSwiftPackageReference "Citadel" */; productName = Citadel; }; - 1BD7D5FD852EEF611DFEF3BA /* KeyJawnKit */ = { + 1BA43024421B2FA71AB6DACD /* NIOTransportServices */ = { isa = XCSwiftPackageProductDependency; - productName = KeyJawnKit; + package = 38E5254CE152976536ED11B9 /* XCRemoteSwiftPackageReference "swift-nio-transport-services" */; + productName = NIOTransportServices; }; - 2C94B23FBDB89626C8A0294C /* NIOPosix */ = { + 1BD7D5FD852EEF611DFEF3BA /* KeyJawnKit */ = { isa = XCSwiftPackageProductDependency; - package = E40D541DF4CADBAB4CD9CCCB /* XCRemoteSwiftPackageReference "swift-nio" */; - productName = NIOPosix; + productName = KeyJawnKit; }; 77314FC70430F6B0FC36684C /* KeyJawnKit */ = { isa = XCSwiftPackageProductDependency; @@ -1020,6 +1066,11 @@ package = 1E63AA3983889C46E6005E9E /* XCRemoteSwiftPackageReference "swift-nio-ssh" */; productName = NIOSSH; }; + B9F4B11DBAC437963B047073 /* NIOTransportServices */ = { + isa = XCSwiftPackageProductDependency; + package = 38E5254CE152976536ED11B9 /* XCRemoteSwiftPackageReference "swift-nio-transport-services" */; + productName = NIOTransportServices; + }; C5D92697896E36FD6161FB69 /* NIOCore */ = { isa = XCSwiftPackageProductDependency; package = E40D541DF4CADBAB4CD9CCCB /* XCRemoteSwiftPackageReference "swift-nio" */; diff --git a/ios/KeyJawn.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/KeyJawn.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a1870ec..6b131d0 100644 --- a/ios/KeyJawn.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/KeyJawn.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "62c687bac9e257d51b3c15fd771cb12188fce5620eda776ddf0bcf30e21d5aec", + "originHash" : "dd35b7b2ead93e3fe45b324ccbadaf0d0a42c2b2ba48d67641f7e7dc9a9f7bd1", "pins" : [ { "identity" : "bigint", @@ -91,6 +91,15 @@ "version" : "0.3.5" } }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5", + "version" : "1.28.0" + } + }, { "identity" : "swift-system", "kind" : "remoteSourceControl", diff --git a/ios/KeyJawn.xcodeproj/xcshareddata/xcschemes/KeyJawn.xcscheme b/ios/KeyJawn.xcodeproj/xcshareddata/xcschemes/KeyJawn.xcscheme new file mode 100644 index 0000000..70e74f0 --- /dev/null +++ b/ios/KeyJawn.xcodeproj/xcshareddata/xcschemes/KeyJawn.xcscheme @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/KeyJawn/App/AppTheme.swift b/ios/KeyJawn/App/AppTheme.swift new file mode 100644 index 0000000..14365f3 --- /dev/null +++ b/ios/KeyJawn/App/AppTheme.swift @@ -0,0 +1,51 @@ +import SwiftUI +import UIKit + +enum AppTheme { + static let backgroundColor = UIColor(red: 10 / 255, green: 10 / 255, blue: 15 / 255, alpha: 1) + static let surfaceColor = UIColor(red: 20 / 255, green: 20 / 255, blue: 28 / 255, alpha: 1) + static let borderColor = UIColor(red: 30 / 255, green: 30 / 255, blue: 42 / 255, alpha: 1) + static let textColor = UIColor(red: 228 / 255, green: 228 / 255, blue: 236 / 255, alpha: 1) + static let secondaryTextColor = UIColor(red: 176 / 255, green: 176 / 255, blue: 196 / 255, alpha: 1) + static let mintColor = UIColor(red: 108 / 255, green: 242 / 255, blue: 168 / 255, alpha: 1) + static let warmColor = UIColor(red: 242 / 255, green: 108 / 255, blue: 138 / 255, alpha: 1) + + static var background: Color { Color(uiColor: backgroundColor) } + static var surface: Color { Color(uiColor: surfaceColor) } + static var border: Color { Color(uiColor: borderColor) } + static var text: Color { Color(uiColor: textColor) } + static var secondaryText: Color { Color(uiColor: secondaryTextColor) } + static var mint: Color { Color(uiColor: mintColor) } + static var warm: Color { Color(uiColor: warmColor) } +} + +struct AppPromptMark: View { + var compact = false + + var body: some View { + HStack(spacing: 1) { + Text(">") + .foregroundStyle(AppTheme.mint) + Text("_") + .foregroundStyle(AppTheme.warm) + } + .font(.system(size: compact ? 20 : 30, weight: .bold, design: .monospaced)) + .accessibilityElement(children: .ignore) + .accessibilityLabel("KeyJawn prompt") + .accessibilityIdentifier("brand.prompt") + } +} + +private struct AppFormBackground: ViewModifier { + func body(content: Content) -> some View { + content + .scrollContentBackground(.hidden) + .background(AppTheme.background.ignoresSafeArea()) + } +} + +extension View { + func appFormBackground() -> some View { + modifier(AppFormBackground()) + } +} diff --git a/ios/KeyJawn/App/ContentView.swift b/ios/KeyJawn/App/ContentView.swift index bb0a05a..bc26610 100644 --- a/ios/KeyJawn/App/ContentView.swift +++ b/ios/KeyJawn/App/ContentView.swift @@ -1,16 +1,13 @@ -import SwiftUI import KeyJawnKit +import SwiftUI struct ContentView: View { private enum Tab: Hashable { - case hosts, preview, settings + case hosts, settings } - // Hosts, not the terminal. The terminal tab has no session behind it, so opening - // the app onto it presented a prompt that echoes keystrokes and connects to - // nothing — a first launch that looks broken. Hosts is where the app actually - // starts: add a server, tap it, get a shell. + // Hosts is the useful starting point: add a remote server, then open its terminal. @State private var selection: Tab = .hosts @State private var showOnboarding = Self.shouldShowOnboarding() @@ -29,14 +26,12 @@ struct ContentView: View { .tabItem { Label("Hosts", systemImage: "server.rack") } .tag(Tab.hosts) - TerminalPreviewView() - .tabItem { Label("Preview", systemImage: "terminal") } - .tag(Tab.preview) - SettingsView(showOnboarding: $showOnboarding) .tabItem { Label("Settings", systemImage: "gearshape") } .tag(Tab.settings) } + .tint(AppTheme.mint) + .background(AppTheme.background.ignoresSafeArea()) .preferredColorScheme(.dark) .fullScreenCover(isPresented: $showOnboarding) { OnboardingView { @@ -45,29 +40,3 @@ struct ContentView: View { } } } - -// MARK: - Terminal preview - -/// A local-echo terminal for trying the extra row without a server. -/// -/// It used to be the app's first tab, labelled simply "Terminal", which made a scratch -/// pad look like a broken SSH session. Named and titled for what it is instead. -struct TerminalPreviewView: View { - var body: some View { - NavigationStack { - TerminalViewControllerRepresentable() - .ignoresSafeArea(edges: .bottom) - .navigationTitle("Preview") - .navigationBarTitleDisplayMode(.inline) - } - } -} - -// MARK: - UIViewControllerRepresentable - -struct TerminalViewControllerRepresentable: UIViewControllerRepresentable { - func makeUIViewController(context: Context) -> TerminalViewController { - TerminalViewController() - } - func updateUIViewController(_ uiViewController: TerminalViewController, context: Context) {} -} diff --git a/ios/KeyJawn/App/KeyJawnApp.swift b/ios/KeyJawn/App/KeyJawnApp.swift index 15595c8..5b583a2 100644 --- a/ios/KeyJawn/App/KeyJawnApp.swift +++ b/ios/KeyJawn/App/KeyJawnApp.swift @@ -2,9 +2,19 @@ import SwiftUI @main struct KeyJawnApp: App { - @StateObject private var hostStore = HostStore() + @StateObject private var hostStore: HostStore init() { + let persistenceMode: HostStore.PersistenceMode + #if DEBUG + let isUITesting = ProcessInfo.processInfo.arguments.contains("-ui-testing") + persistenceMode = isUITesting ? .inMemory : .keychain + #else + persistenceMode = .keychain + #endif + _hostStore = StateObject( + wrappedValue: HostStore(persistenceMode: persistenceMode) + ) // Migrate the pre-shared-keychain item before the extension can request it. // This preserves every existing authorized_keys entry instead of rotating. SSHKeyStore.shared.prepareSharedIdentity() diff --git a/ios/KeyJawn/App/OnboardingView.swift b/ios/KeyJawn/App/OnboardingView.swift index 7dfffc4..8e54d41 100644 --- a/ios/KeyJawn/App/OnboardingView.swift +++ b/ios/KeyJawn/App/OnboardingView.swift @@ -1,6 +1,6 @@ +import KeyJawnKit import SwiftUI import UIKit -import KeyJawnKit /// First-launch keyboard setup. Skippable. Reopened from Settings. /// @@ -15,15 +15,36 @@ struct OnboardingView: View { var body: some View { NavigationStack { - VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 24) { let current = OnboardingCopy.pages[page] + HStack(spacing: 12) { + AppPromptMark() + VStack(alignment: .leading, spacing: 2) { + Text("KeyJawn") + .font(.title2.bold().monospaced()) + .foregroundStyle(AppTheme.text) + Text("Terminal keys for work on the move") + .font(.subheadline) + .foregroundStyle(AppTheme.secondaryText) + } + } + + HStack(spacing: 8) { + ForEach(OnboardingCopy.pages.indices, id: \.self) { index in + Capsule() + .fill(index <= page ? AppTheme.mint : AppTheme.border) + .frame(height: 4) + } + } + .accessibilityHidden(true) + ScrollView { VStack(alignment: .leading, spacing: 20) { Text(current.title) - .font(.title2) - .fontWeight(.semibold) + .font(.title.bold()) + .foregroundStyle(AppTheme.text) Text(current.body) - .foregroundStyle(.secondary) + .foregroundStyle(AppTheme.secondaryText) if page == 1 { Button(OnboardingCopy.openSettingsTitle) { if let url = URL(string: UIApplication.openSettingsURLString) { @@ -31,9 +52,17 @@ struct OnboardingView: View { } } .buttonStyle(.bordered) + .tint(AppTheme.mint) } } + .padding(24) .frame(maxWidth: .infinity, alignment: .leading) + .background(AppTheme.surface) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 20, style: .continuous) + .stroke(AppTheme.border, lineWidth: 1) + } } HStack { Button(OnboardingCopy.skipTitle) { @@ -51,6 +80,7 @@ struct OnboardingView: View { } } .buttonStyle(.borderedProminent) + .tint(AppTheme.mint) .accessibilityIdentifier( page == OnboardingCopy.pages.count - 1 ? "onboarding.done" @@ -58,7 +88,8 @@ struct OnboardingView: View { ) } } - .padding() + .padding(24) + .background(AppTheme.background.ignoresSafeArea()) .navigationTitle("Set up") .navigationBarTitleDisplayMode(.inline) } diff --git a/ios/KeyJawn/Assets.xcassets/LaunchScreenBackground.colorset/Contents.json b/ios/KeyJawn/Assets.xcassets/LaunchScreenBackground.colorset/Contents.json index 499b790..ddbdedd 100644 --- a/ios/KeyJawn/Assets.xcassets/LaunchScreenBackground.colorset/Contents.json +++ b/ios/KeyJawn/Assets.xcassets/LaunchScreenBackground.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space": "srgb", "components": { "alpha": "1.000", - "blue": "0.102", - "green": "0.102", - "red": "0.102" + "blue": "0.059", + "green": "0.039", + "red": "0.039" } }, "idiom": "universal" diff --git a/ios/KeyJawn/Hosts/HostEditView.swift b/ios/KeyJawn/Hosts/HostEditView.swift index 83e31ed..bb04233 100644 --- a/ios/KeyJawn/Hosts/HostEditView.swift +++ b/ios/KeyJawn/Hosts/HostEditView.swift @@ -1,6 +1,6 @@ -import SwiftUI import KeyJawnKit @preconcurrency import NIOSSH +import SwiftUI /// Add or edit a host. /// @@ -20,6 +20,7 @@ struct HostEditView: View { @State private var port: String @State private var username: String @State private var authMethod: HostConfig.AuthMethod + @State private var usesTLSTunnel: Bool @State private var hostPublicKey: String @State private var uploadPath: String @@ -31,6 +32,7 @@ struct HostEditView: View { _port = State(initialValue: host.map { String($0.port) } ?? "22") _username = State(initialValue: host?.username ?? "") _authMethod = State(initialValue: host?.authMethod ?? .key) + _usesTLSTunnel = State(initialValue: host?.usesTLSTunnel ?? false) _hostPublicKey = State(initialValue: host?.hostPublicKey ?? "") _uploadPath = State(initialValue: host?.uploadPath ?? "/tmp") } @@ -49,20 +51,18 @@ struct HostEditView: View { /// Built from what has been typed so far, so a non-default port shows up as `-p` /// in the instructions rather than sending the user to scan port 22. - private var scanCommand: String { + private var scanCommand: String? { let trimmedHost = hostname.trimmingCharacters(in: .whitespaces) return HostConfig.hostKeyScanCommand( hostname: trimmedHost.isEmpty ? "" : trimmedHost, - port: parsedPort ?? 22 + port: parsedPort ?? 22, + usesTLSTunnel: usesTLSTunnel ) } private var isValid: Bool { - !label.trimmingCharacters(in: .whitespaces).isEmpty && - !hostname.trimmingCharacters(in: .whitespaces).isEmpty && - !username.trimmingCharacters(in: .whitespaces).isEmpty && - parsedPort != nil && - isHostKeyValid + !label.trimmingCharacters(in: .whitespaces).isEmpty && !hostname.trimmingCharacters(in: .whitespaces).isEmpty + && !username.trimmingCharacters(in: .whitespaces).isEmpty && parsedPort != nil && isHostKeyValid } var body: some View { @@ -70,7 +70,7 @@ struct HostEditView: View { Form { Section("Connection") { LabeledContent("Label") { - TextField("e.g. houseofjawn", text: $label) + TextField("e.g. remote server", text: $label) .multilineTextAlignment(.trailing) .autocorrectionDisabled() } @@ -92,6 +92,14 @@ struct HostEditView: View { .autocorrectionDisabled() .textInputAutocapitalization(.never) } + Toggle("TLS tunnel", isOn: $usesTLSTunnel) + if usesTLSTunnel { + Text( + "TLS requires a hostname or IP address that matches the tunnel certificate." + ) + .font(.caption) + .foregroundStyle(.secondary) + } } Section { @@ -105,15 +113,29 @@ struct HostEditView: View { } footer: { let trimmed = hostPublicKey.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty && !isHostKeyValid { - Text("Invalid key format. Paste the key type and key data from the output of: \(scanCommand)") + if let scanCommand { + Text( + "Invalid key format. Paste the key type and key data from the output of: \(scanCommand)" + ) .font(.caption) .foregroundStyle(.red) + } else { + Text("Invalid key format. Paste an OpenSSH host key.") + .font(.caption) + .foregroundStyle(.red) + } + } else if let scanCommand { + Text( + "Leave blank to review and save the server fingerprint on first connection. " + + "Or paste the key type and key data from the output of: \(scanCommand)" + ) + .font(.caption) } else { Text( "Leave blank to review and save the server fingerprint on first connection. " - + "Or paste the key type and key data from the output of: \(scanCommand)" + + "A plain ssh-keyscan command cannot connect through a TLS tunnel." ) - .font(.caption) + .font(.caption) } } @@ -138,14 +160,20 @@ struct HostEditView: View { .multilineTextAlignment(.trailing) .autocorrectionDisabled() .textInputAutocapitalization(.never) + .disabled(authMethod != .key) } } header: { - Text("SCP upload") + Text("Copied-image upload") } footer: { - Text("Remote directory where the keyboard extension uploads images via SFTP.") - .font(.caption) + Text( + authMethod == .key + ? "Remote directory where KeyJawn uploads copied images through SFTP." + : "Copied-image upload requires SSH key authentication. Terminal connections can still use a password." + ) + .font(.caption) } } + .appFormBackground() .navigationTitle(existing == nil ? "Add host" : "Edit host") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -172,6 +200,7 @@ struct HostEditView: View { port: parsedPort ?? 22, username: username.trimmingCharacters(in: .whitespaces), authMethod: authMethod, + usesTLSTunnel: usesTLSTunnel, hostPublicKey: trimmedKey.isEmpty ? nil : trimmedKey, uploadPath: trimmedPath.isEmpty ? "/tmp" : trimmedPath ) diff --git a/ios/KeyJawn/Hosts/HostListView.swift b/ios/KeyJawn/Hosts/HostListView.swift index ac7f0bf..5c1c7d2 100644 --- a/ios/KeyJawn/Hosts/HostListView.swift +++ b/ios/KeyJawn/Hosts/HostListView.swift @@ -1,5 +1,5 @@ -import SwiftUI import KeyJawnKit +import SwiftUI struct HostListView: View { @EnvironmentObject private var hostStore: HostStore @@ -15,6 +15,7 @@ struct HostListView: View { list } } + .background(AppTheme.background.ignoresSafeArea()) .navigationTitle("Hosts") .navigationBarTitleDisplayMode(.large) .toolbar { @@ -42,21 +43,31 @@ struct HostListView: View { private var emptyState: some View { VStack(spacing: 16) { - Image(systemName: "server.rack") - .font(.system(size: 48)) - .foregroundStyle(.secondary) + AppPromptMark() Text("No hosts yet") .font(.title3) .fontWeight(.semibold) - Text("Add an SSH host to start a terminal session.") - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 40) + .foregroundStyle(AppTheme.text) + Text( + "Connect to a remote SSH server. Commands run on that server. " + + "KeyJawn cannot browse files on your iPhone or iPad." + ) + .foregroundStyle(AppTheme.secondaryText) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) Button("Add host") { showingAddHost = true } .buttonStyle(.borderedProminent) } + .padding(28) + .background(AppTheme.surface) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 20, style: .continuous) + .stroke(AppTheme.border, lineWidth: 1) + } + .padding(24) } private var list: some View { @@ -88,6 +99,7 @@ struct HostListView: View { hostStore.delete(at: indices) } } + .appFormBackground() .navigationDestination(for: HostConfig.self) { host in HostTerminalView(host: host) } diff --git a/ios/KeyJawn/Hosts/PasswordPromptView.swift b/ios/KeyJawn/Hosts/PasswordPromptView.swift index 171a966..0b6b9f4 100644 --- a/ios/KeyJawn/Hosts/PasswordPromptView.swift +++ b/ios/KeyJawn/Hosts/PasswordPromptView.swift @@ -1,5 +1,5 @@ -import SwiftUI import KeyJawnKit +import SwiftUI struct PasswordPromptView: View { let host: HostConfig @@ -15,6 +15,10 @@ struct PasswordPromptView: View { LabeledContent("Host") { Text(host.hostname).foregroundStyle(.secondary) } LabeledContent("User") { Text(host.username).foregroundStyle(.secondary) } LabeledContent("Port") { Text("\(host.port)").foregroundStyle(.secondary) } + LabeledContent("Transport") { + Text(host.usesTLSTunnel ? "TLS tunnel" : "SSH") + .foregroundStyle(.secondary) + } } Section("Authentication") { SecureField("Password", text: $password) @@ -22,6 +26,7 @@ struct PasswordPromptView: View { .autocorrectionDisabled() } } + .appFormBackground() .navigationTitle("Connect to \(host.label)") .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/ios/KeyJawn/Info.plist b/ios/KeyJawn/Info.plist index ff3c17a..453f504 100644 --- a/ios/KeyJawn/Info.plist +++ b/ios/KeyJawn/Info.plist @@ -18,29 +18,16 @@ APPL CFBundleShortVersionString $(MARKETING_VERSION) - CFBundleURLTypes - - - CFBundleURLName - com.keyjawn - CFBundleURLSchemes - - keyjawn - - - CFBundleVersion $(CURRENT_PROJECT_VERSION) KeychainAccessGroup $(AppIdentifierPrefix)com.keyjawn.shared - LegacyKeychainAccessGroup - $(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER) LSRequiresIPhoneOS + LegacyKeychainAccessGroup + $(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER) NSMicrophoneUsageDescription Voice input for terminal prompts - NSPhotoLibraryUsageDescription - Upload images via SFTP to insert paths into prompts NSSpeechRecognitionUsageDescription Voice-to-text for typing prompts to CLI agents UILaunchScreen diff --git a/ios/KeyJawn/Settings/SSHKeysView.swift b/ios/KeyJawn/Settings/SSHKeysView.swift index 2208815..a6f2598 100644 --- a/ios/KeyJawn/Settings/SSHKeysView.swift +++ b/ios/KeyJawn/Settings/SSHKeysView.swift @@ -43,6 +43,7 @@ struct SSHKeysView: View { .font(.caption) } } + .appFormBackground() .navigationTitle("SSH keys") .navigationBarTitleDisplayMode(.inline) .onAppear { diff --git a/ios/KeyJawn/Settings/SettingsView.swift b/ios/KeyJawn/Settings/SettingsView.swift index d25a051..ef00bda 100644 --- a/ios/KeyJawn/Settings/SettingsView.swift +++ b/ios/KeyJawn/Settings/SettingsView.swift @@ -1,5 +1,5 @@ -import SwiftUI import KeyJawnKit +import SwiftUI struct SettingsView: View { @@ -21,6 +21,21 @@ struct SettingsView: View { var body: some View { NavigationStack { Form { + Section { + HStack(spacing: 12) { + AppPromptMark(compact: true) + VStack(alignment: .leading, spacing: 2) { + Text("KeyJawn") + .font(.headline.monospaced()) + Text("Terminal keyboard and remote SSH") + .font(.caption) + .foregroundStyle(AppTheme.secondaryText) + } + } + .accessibilityElement(children: .combine) + .accessibilityIdentifier("settings.brand") + } + Section("SSH") { NavigationLink("SSH keys") { SSHKeysView() @@ -143,6 +158,7 @@ struct SettingsView: View { Link("Manual", destination: URL(string: "https://keyjawn.amditis.tech/manual")!) } } + .appFormBackground() .navigationTitle("Settings") } } diff --git a/ios/KeyJawn/Store/HostStore.swift b/ios/KeyJawn/Store/HostStore.swift index b2480eb..db0ee57 100644 --- a/ios/KeyJawn/Store/HostStore.swift +++ b/ios/KeyJawn/Store/HostStore.swift @@ -11,14 +11,22 @@ import KeyJawnKit @MainActor final class HostStore: ObservableObject { + enum PersistenceMode: Equatable { + case keychain, inMemory + } + @Published private(set) var hosts: [HostConfig] = [] private let service = "com.keyjawn" private let account = "keyjawn.hosts" private let legacyDefaultsKey = "keyjawn.hosts" + private let persistenceMode: PersistenceMode - init() { - load() + init(persistenceMode: PersistenceMode = .keychain) { + self.persistenceMode = persistenceMode + if persistenceMode == .keychain { + load() + } } // MARK: - Mutations @@ -67,6 +75,7 @@ final class HostStore: ObservableObject { @discardableResult private func save() -> Bool { + guard persistenceMode == .keychain else { return true } guard let data = try? JSONEncoder().encode(hosts), keychainSave(data) else { return false } // Mirror to App Group so the keyboard extension can read host configs. diff --git a/ios/KeyJawn/Terminal/SSHSession.swift b/ios/KeyJawn/Terminal/SSHSession.swift index b1c1d3a..7c7e6f9 100644 --- a/ios/KeyJawn/Terminal/SSHSession.swift +++ b/ios/KeyJawn/Terminal/SSHSession.swift @@ -1,15 +1,14 @@ -import Foundation import Citadel +import Foundation +import KeyJawnKit import NIOCore -import NIOPosix @preconcurrency import NIOSSH -import KeyJawnKit -// Citadel doesn't yet declare Sendable conformances for these types, -// but the underlying NIO Channel, AsyncThrowingStream, and auth delegate are thread-safe. -extension TTYOutput: @unchecked @retroactive Sendable {} -extension TTYStdinWriter: @unchecked @retroactive Sendable {} -extension SSHAuthenticationMethod: @unchecked @retroactive Sendable {} +/// Carries Citadel terminal values into child tasks without changing the +/// third-party types' global concurrency contract. NIO owns their thread safety. +private struct SendableSSHValue: @unchecked Sendable { + let value: Value +} /// Manages a single interactive SSH session via Citadel/NIO. /// @@ -97,6 +96,7 @@ final class SSHSession: ObservableObject { self.pendingHostKey = presentedKey } } + let appleAuthentication = AppleNetworkSSHAuthentication(authenticationMethod) let pinnedHostKey: NIOSSHPublicKey? do { @@ -109,11 +109,10 @@ final class SSHSession: ObservableObject { guard let pinnedHostKey else { sessionTask = Task.detached { - [host, authenticationMethod, captureHostKey, onStateChange] in + [host, captureHostKey, onStateChange] in do { try await probeFirstUseHostKey( host: host, - authenticationMethod: authenticationMethod, captureHostKey: captureHostKey ) onStateChange(.awaitingHostKey) @@ -123,7 +122,9 @@ final class SSHSession: ObservableObject { if Task.isCancelled { onStateChange(.disconnected) } else { - onStateChange(.failed(error.localizedDescription)) + onStateChange( + .failed(sessionUserFacingError(error).localizedDescription) + ) } } } @@ -136,16 +137,22 @@ final class SSHSession: ObservableObject { inputContinuation = inputCont resizeContinuation = resizeCont - sessionTask = Task.detached { [host, authenticationMethod, inputStream, resizeStream, coalescer, onStateChange, validator] in + sessionTask = Task.detached { + [host, appleAuthentication, inputStream, resizeStream, coalescer, onStateChange, validator] in + var operationDeadline: AppleNetworkOperationDeadline? do { - let client = try await SSHClient.connect( + let connection = try await AppleNetworkSSHTransport.connect( host: host.hostname, port: Int(host.port), - authenticationMethod: authenticationMethod, - hostKeyValidator: validator, - reconnect: .never + useTLS: host.usesTLSTunnel, + authentication: appleAuthentication, + hostKeyValidator: validator + ) + let client = connection.client + defer { connection.channel.close(promise: nil) } + let channelCloser = AppleNetworkChannelCloser( + channel: connection.channel ) - onStateChange(.connected) let ptyRequest = SSHChannelRequestEvent.PseudoTerminalRequest( wantReply: true, @@ -156,42 +163,60 @@ final class SSHSession: ObservableObject { terminalPixelHeight: 0, terminalModes: .init([:]) ) - - try await client.withPTY(ptyRequest) { ttyOutput, stdinWriter in - await withThrowingTaskGroup(of: Void.self) { group in - // Pump SSH output → coalescer → main-actor terminal feed - group.addTask { - for try await chunk in ttyOutput { - // Copy the readable bytes straight into the - // coalescer's pending batch; it owns the single - // main-actor hop and the array hand-off. - switch chunk { - case .stdout(let buf): coalescer.append(buf.readableBytesView) - case .stderr(let buf): coalescer.append(buf.readableBytesView) + let ptyDeadline = AppleNetworkOperationDeadline( + channel: connection.channel, + timeout: .seconds(30) + ) + operationDeadline = ptyDeadline + defer { ptyDeadline.cancel() } + + try await withTaskCancellationHandler { + try await client.withPTY(ptyRequest) { ttyOutput, stdinWriter in + // Citadel enters this closure only after the server creates the + // PTY. Do not report connected before that setup succeeds. + guard ptyDeadline.complete() else { + throw AppleNetworkSSHTransportError.operationTimedOut + } + onStateChange(.connected) + let output = SendableSSHValue(value: ttyOutput) + let writer = SendableSSHValue(value: stdinWriter) + await withThrowingTaskGroup(of: Void.self) { group in + // Pump SSH output → coalescer → main-actor terminal feed + group.addTask { + for try await chunk in output.value { + // Copy the readable bytes straight into the + // coalescer's pending batch; it owns the single + // main-actor hop and the array hand-off. + switch chunk { + case .stdout(let buf): coalescer.append(buf.readableBytesView) + case .stderr(let buf): coalescer.append(buf.readableBytesView) + } } } - } - // Pump keyboard input → SSH channel - group.addTask { - for await bytes in inputStream { - var buf = ByteBufferAllocator().buffer(capacity: bytes.count) - buf.writeBytes(bytes) - try await stdinWriter.write(buf) + // Pump keyboard input → SSH channel + group.addTask { + for await bytes in inputStream { + var buf = ByteBufferAllocator().buffer(capacity: bytes.count) + buf.writeBytes(bytes) + try await writer.value.write(buf) + } } - } - // Handle terminal resize requests - group.addTask { - for await (cols, rows) in resizeStream { - try await stdinWriter.changeSize( - cols: cols, rows: rows, - pixelWidth: 0, pixelHeight: 0 - ) + // Handle terminal resize requests + group.addTask { + for await (cols, rows) in resizeStream { + try await writer.value.changeSize( + cols: cols, rows: rows, + pixelWidth: 0, pixelHeight: 0 + ) + } } + // Stop when any stream closes + _ = try? await group.next() + group.cancelAll() } - // Stop when any stream closes - _ = try? await group.next() - group.cancelAll() } + } onCancel: { + channelCloser.close() } onStateChange(.disconnected) @@ -200,13 +225,23 @@ final class SSHSession: ObservableObject { } catch { if Task.isCancelled { onStateChange(.disconnected) + } else if operationDeadline?.didTimeOut == true { + onStateChange( + .failed( + AppleNetworkSSHTransportError.operationTimedOut + .localizedDescription + ) + ) } else if error is InvalidHostKey { - onStateChange(.failed( - "REMOTE HOST IDENTIFICATION HAS CHANGED for \(host.hostname). " - + "Verify the server before replacing the saved host key." - )) + onStateChange( + .failed( + "REMOTE HOST IDENTIFICATION HAS CHANGED for \(host.hostname). " + + "Verify the server before replacing the saved host key." + )) } else { - onStateChange(.failed(error.localizedDescription)) + onStateChange( + .failed(sessionUserFacingError(error).localizedDescription) + ) } } } @@ -226,9 +261,10 @@ final class SSHSession: ObservableObject { /// Starts a fresh, pinned connection after the caller has durably stored the key. func connectAfterTrust(to host: HostConfig) { guard let presentedKey = pendingHostKey, - let storedKey = host.hostPublicKey, - (try? presentedKey.matches(openSSHKey: storedKey)) == true, - let authenticationMethod = pendingAuthenticationMethod else { + let storedKey = host.hostPublicKey, + (try? presentedKey.matches(openSSHKey: storedKey)) == true, + let authenticationMethod = pendingAuthenticationMethod + else { rejectPendingHostKey(.invalidPresentedKey) return } @@ -279,6 +315,13 @@ private func hostPublicKey(from host: HostConfig) throws -> NIOSSHPublicKey? { private struct HostKeyParseError: Error {} +private func sessionUserFacingError(_ error: Error) -> Error { + if error is HostKeyTrustError || error is InvalidHostKey { + return error + } + return AppleNetworkSSHTransport.userFacingError(error) +} + enum HostKeyTrustError: Error, LocalizedError, Sendable, Equatable { case trustRequired case rejected @@ -350,10 +393,9 @@ final class TOFUHostKeyValidator: NIOSSHClientServerAuthenticationDelegate, @unc /// unauthenticated connection remains open while the user reviews the fingerprint. private func probeFirstUseHostKey( host: HostConfig, - authenticationMethod: SSHAuthenticationMethod, captureHostKey: @escaping TOFUHostKeyValidator.CaptureHostKey ) async throws { - let channelBox = HostKeyProbeChannelBox() + let channelBox = AppleNetworkChannelBox() let (presentedKeys, presentedKeyContinuation) = AsyncThrowingStream.makeStream() let validator = TOFUHostKeyValidator( @@ -376,31 +418,38 @@ private func probeFirstUseHostKey( channelBox.close() } - let bootstrap = ClientBootstrap(group: MultiThreadedEventLoopGroup.singleton) + let bootstrap = AppleNetworkSSHTransport.bootstrap(useTLS: host.usesTLSTunnel) .channelInitializer { channel in channelBox.store(channel) let configuration = SSHClientConfiguration( // Host-key validation runs before user authentication. The probe - // rejects and closes at that boundary, so this delegate is retained - // for the later pinned connection but never offers a credential here. - userAuthDelegate: authenticationMethod, + // rejects and closes at that boundary. This delegate cannot expose + // a password or private key even if the protocol asks for one. + userAuthDelegate: NoCredentialSSHAuthenticationDelegate(), serverAuthDelegate: validator ) - return channel.pipeline.addHandlers( - NIOSSHHandler( - role: .client(configuration), - allocator: channel.allocator, - inboundChildChannelInitializer: nil - ), - errorHandler - ) + return channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHandlers( + NIOSSHHandler( + role: .client(configuration), + allocator: channel.allocator, + inboundChildChannelInitializer: nil + ), + errorHandler + ) + } } .connectTimeout(.seconds(30)) - _ = try await bootstrap.connect( - host: host.hostname, - port: Int(host.port) - ).get() + do { + _ = try await bootstrap.connect( + host: host.hostname, + port: Int(host.port) + ).get() + } catch { + try Task.checkCancellation() + throw AppleNetworkSSHTransport.connectionError(error) + } _ = try await nextPresentedHostKey( from: presentedKeys, @@ -418,6 +467,18 @@ private func probeFirstUseHostKey( } } +/// Refuses every authentication method during the host-key-only probe. +private final class NoCredentialSSHAuthenticationDelegate: + NIOSSHClientUserAuthenticationDelegate, @unchecked Sendable +{ + func nextAuthenticationType( + availableMethods: NIOSSHAvailableUserAuthenticationMethods, + nextChallengePromise: EventLoopPromise + ) { + nextChallengePromise.succeed(nil) + } +} + func nextPresentedHostKey( from presentedKeys: AsyncThrowingStream, timeout: Duration, @@ -443,33 +504,6 @@ func nextPresentedHostKey( } } -/// Retains the probe channel across task cancellation and closes it at most once. -private final class HostKeyProbeChannelBox: @unchecked Sendable { - private let lock = NSLock() - private var channel: Channel? - private var closeRequested = false - - func store(_ channel: Channel) { - lock.lock() - if closeRequested { - lock.unlock() - channel.close(promise: nil) - } else { - self.channel = channel - lock.unlock() - } - } - - func close() { - lock.lock() - closeRequested = true - let channel = channel - self.channel = nil - lock.unlock() - channel?.close(promise: nil) - } -} - /// Converts every probe pipeline failure or early disconnect into stream completion /// and closes the owned socket. This is a backup for failures that occur before a /// well-formed host key reaches the validator. diff --git a/ios/KeyJawn/Terminal/TerminalInputView.swift b/ios/KeyJawn/Terminal/TerminalInputView.swift index 781e27d..badadf8 100644 --- a/ios/KeyJawn/Terminal/TerminalInputView.swift +++ b/ios/KeyJawn/Terminal/TerminalInputView.swift @@ -1,5 +1,5 @@ -import UIKit import KeyJawnKit +import UIKit /// Transparent UITextView used as a keyboard sink on top of SwiftTerm's /// TerminalView. Intercepts insertText/deleteBackward to route raw bytes @@ -16,13 +16,14 @@ final class TerminalInputView: UITextView { /// the accessory sees the same list as Settings. Tests inject a snapshot. var uploadHosts: () -> [HostConfig] = { HostStore().hosts } - /// Private key bytes for the in-app SCP upload. Defaults to the shared + /// Private key bytes for the in-app remote upload. Defaults to the shared /// identity. Tests inject a stub. var uploadPrivateKeyData: () -> Data? = { SSHKeyStore.shared.privateKey.rawRepresentation } private var slashPanel: SlashCommandPanel? private var clipboardPanel: ClipboardPanel? private var uploadPanel: UploadPanel? + private var uploadTask: Task? private let voice = TerminalVoiceInput() private var lastHardwareEmit: (bytes: [UInt8], at: CFTimeInterval)? @@ -37,21 +38,21 @@ final class TerminalInputView: UITextView { } private func setup() { - backgroundColor = .clear - textColor = .clear - tintColor = .clear - autocorrectionType = .no - autocapitalizationType = .none - spellCheckingType = .no + backgroundColor = .clear + textColor = .clear + tintColor = .clear + autocorrectionType = .no + autocapitalizationType = .none + spellCheckingType = .no // Smart substitution turns a typed "--flag" into an en dash and quotes into // curly ones, which reach the shell as characters it does not understand. - smartQuotesType = .no - smartDashesType = .no - smartInsertDeleteType = .no + smartQuotesType = .no + smartDashesType = .no + smartInsertDeleteType = .no - extraRow.frame = CGRect(x: 0, y: 0, width: 0, height: 52) + extraRow.frame = CGRect(x: 0, y: 0, width: 0, height: 52) extraRow.setKeys(KeyboardPrefs.shared.extraRowPreset.terminalKeys) - extraRow.delegate = self + extraRow.delegate = self // Same theme the keyboard extension uses, so the row looks like one component // wherever it appears rather than defaulting to dark here and themed there. extraRow.applyTheme(KeyboardPrefs.shared.theme) @@ -87,10 +88,11 @@ final class TerminalInputView: UITextView { // characters so a multi-character insertion (a paste, a dictation result) is // not silently collapsed into one control byte. if extraRow.ctrl.isActive, - text.count == 1, - text != "\n", - text != "\r", - let bytes = ANSISequence.bytes(for: .character(text), ctrlActive: true) { + text.count == 1, + text != "\n", + text != "\r", + let bytes = ANSISequence.bytes(for: .character(text), ctrlActive: true) + { onRawInput?(bytes) extraRow.ctrl.consume() return @@ -105,18 +107,7 @@ final class TerminalInputView: UITextView { } override func deleteBackward() { - onRawInput?([0x7f]) // DEL - } - - /// Inserts a non-submit newline (LF). Used by long-press Send so a - /// multiline prompt can gain a line without submitting to the agent. - func insertNewlineWithoutSubmit() { - onRawInput?(TerminalInputMapping.newlineBytes) - } - - /// Writes the submit byte (CR) the same way a system Return does. - func submitLine() { - onRawInput?(TerminalInputMapping.submitBytes) + onRawInput?([0x7f]) // DEL } // MARK: Hardware keyboard @@ -139,7 +130,8 @@ final class TerminalInputView: UITextView { var handled = false for press in presses { guard let key = press.key, - let mapped = hardwareKey(from: key) else { continue } + let mapped = hardwareKey(from: key) + else { continue } let modifiers = hardwareModifiers(from: key) if emitHardware(mapped, modifiers: modifiers) { handled = true @@ -220,7 +212,8 @@ extension TerminalInputView: ExtraRowDelegate { } func extraRowDidTapUpload(_ view: ExtraRowView) { - if uploadPanel != nil { + if let uploadPanel { + guard uploadPanel.isDismissEnabled else { return } hideUploadPanel() } else { showUploadPanel() @@ -318,13 +311,30 @@ extension TerminalInputView { let theme = KeyboardPrefs.shared.theme let panel = UploadPanel(theme: theme) - let hosts = uploadHosts() + let allHosts = uploadHosts() + let hosts = HostConfig.copiedImageUploadHosts(from: allHosts) panel.hosts = hosts - panel.emptyReason = .noHostsConfigured + if allHosts.isEmpty { + panel.emptyReason = .noHostsConfigured + } else if hosts.isEmpty && allHosts.contains(where: { $0.authMethod == .key }) { + panel.emptyReason = .hostKeyVerificationRequired + } else { + panel.emptyReason = .keyAuthenticationRequired + } panel.onDismiss = { [weak self] in self?.hideUploadPanel() } + guard !hosts.isEmpty else { + panel.statusMessage = panel.emptyReason.message + panel.isUploadEnabled = false + panel.onUpload = { _ in } + showOverlay(panel) + uploadPanel = panel + return + } + guard let rawImageData = UIPasteboard.general.firstImageData else { - panel.statusMessage = UIPasteboard.general.hasImages + panel.statusMessage = + UIPasteboard.general.hasImages ? "Couldn't read that image. Try copying it again." : "Copy an image first, then tap SCP" panel.isUploadEnabled = false @@ -334,7 +344,7 @@ extension TerminalInputView { return } - panel.statusMessage = "Preparing image..." + panel.statusMessage = "Preparing copied image for remote upload..." panel.isUploadEnabled = false panel.onUpload = { _ in } showOverlay(panel) @@ -350,17 +360,17 @@ extension TerminalInputView { panel.statusMessage = "Couldn't read that image. Try copying it again." return } - panel.statusMessage = hosts.isEmpty - ? "No hosts configured. Add one in Settings." - : "Select a host to upload" + panel.statusMessage = "Select a remote SSH host" panel.onUpload = { [weak self] host in self?.performUpload(imageData: imageData, to: host) } - panel.isUploadEnabled = !hosts.isEmpty + panel.isUploadEnabled = true } } private func hideUploadPanel() { + uploadTask?.cancel() + uploadTask = nil uploadPanel?.removeFromSuperview() uploadPanel = nil } @@ -372,19 +382,33 @@ extension TerminalInputView { } uploadPanel?.statusMessage = "Uploading to \(host.label)..." uploadPanel?.isUploadEnabled = false - - Task { @MainActor in + guard let panel = uploadPanel else { return } + panel.isDismissEnabled = false + + uploadTask = Task { @MainActor [weak self, weak panel] in + guard let self, let panel else { return } + defer { + if self.uploadPanel === panel { + self.uploadTask = nil + } + } do { let path = try await CitadelSCPUploader.upload( imageData: imageData, to: host, privateKeyData: keyData ) + guard !Task.isCancelled, self.uploadPanel === panel else { return } + self.uploadTask = nil self.onRawInput?(Array(path.utf8)) self.hideUploadPanel() + } catch is CancellationError { + return } catch { - self.uploadPanel?.statusMessage = "Upload failed: \(error.localizedDescription)" - self.uploadPanel?.isUploadEnabled = true + guard self.uploadPanel === panel else { return } + panel.statusMessage = "Upload failed: \(error.localizedDescription)" + panel.isUploadEnabled = true + panel.isDismissEnabled = true } } } diff --git a/ios/KeyJawn/Terminal/TerminalViewController.swift b/ios/KeyJawn/Terminal/TerminalViewController.swift index 78c2a29..1e2c0e0 100644 --- a/ios/KeyJawn/Terminal/TerminalViewController.swift +++ b/ios/KeyJawn/Terminal/TerminalViewController.swift @@ -5,10 +5,6 @@ import Combine /// Full-screen terminal view controller. /// -/// When `session` is nil, runs in local-echo mode (for testing without SSH). -/// When a session is provided, all input routes to the SSH channel and all -/// server output is fed into SwiftTerm. -/// /// Terminal resize is handled automatically: SwiftTerm fires `sizeChanged` /// whenever it recomputes the grid, and we immediately forward that to the /// PTY via SSHSession.resize(cols:rows:). A Combine sink sends the initial @@ -19,7 +15,7 @@ final class TerminalViewController: UIViewController { private var terminalView: TerminalView! private let inputSink = TerminalInputView() - private let session: SSHSession? + private let session: SSHSession // Latest terminal dimensions from SwiftTerm's sizeChanged callback. private var terminalCols = 80 @@ -29,14 +25,13 @@ final class TerminalViewController: UIViewController { // MARK: - Init - init(session: SSHSession? = nil) { + init(session: SSHSession) { self.session = session super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { - self.session = nil - super.init(coder: coder) + fatalError("use init(session:)") } // MARK: - Lifecycle @@ -67,7 +62,7 @@ final class TerminalViewController: UIViewController { // disconnect() is idempotent, so the toolbar Disconnect button having // fired first is fine. if isBeingRemoved { - session?.disconnect() + session.disconnect() } } @@ -107,14 +102,6 @@ final class TerminalViewController: UIViewController { terminalView.leadingAnchor.constraint(equalTo: view.leadingAnchor), terminalView.trailingAnchor.constraint(equalTo: view.trailingAnchor), ]) - - if session == nil { - // Local echo, no server. Say so, so this does not read as a session that - // failed to connect. - terminalView.feed(text: "KeyJawn preview — local echo, not connected.\r\n") - terminalView.feed(text: "Tap to open the keyboard and try the terminal row.\r\n") - terminalView.feed(text: "Add a server in the Hosts tab for a real shell.\r\n\r\n") - } } private func setupInputSink() { @@ -130,18 +117,11 @@ final class TerminalViewController: UIViewController { ]) inputSink.onRawInput = { [weak self] bytes in - guard let self else { return } - if let session = self.session { - session.send(bytes) - } else { - self.terminalView.feed(byteArray: ArraySlice(bytes)) - } + self?.session.send(bytes) } } private func wireSession() { - guard let session else { return } - session.onData = { [weak self] bytes in self?.terminalView.feed(byteArray: ArraySlice(bytes)) } @@ -153,7 +133,7 @@ final class TerminalViewController: UIViewController { .receive(on: RunLoop.main) .sink { [weak self] state in guard let self, case .connected = state else { return } - self.session?.resize(cols: self.terminalCols, rows: self.terminalRows) + self.session.resize(cols: self.terminalCols, rows: self.terminalRows) } .store(in: &cancellables) } @@ -167,7 +147,7 @@ extension TerminalViewController: @preconcurrency TerminalViewDelegate { terminalCols = newCols terminalRows = newRows // Forward live resize events (device rotation, split-view) to the PTY. - session?.resize(cols: newCols, rows: newRows) + session.resize(cols: newCols, rows: newRows) } func setTerminalTitle(source: TerminalView, title: String) {} @@ -178,10 +158,9 @@ extension TerminalViewController: @preconcurrency TerminalViewDelegate { func clipboardCopy(source: TerminalView, content: Data) {} func rangeChanged(source: TerminalView, startY: Int, endY: Int) {} + // User input enters through TerminalInputView. This callback carries replies + // generated by the emulator for the remote terminal, such as status reports. func send(source: TerminalView, data: ArraySlice) { - // In SSH mode all input flows through TerminalInputView, so we ignore - // this delegate callback to avoid double-sending to the SSH channel. - guard session == nil else { return } - terminalView.feed(byteArray: data) + session.send(Array(data)) } } diff --git a/ios/KeyJawnKeyboard/Info.plist b/ios/KeyJawnKeyboard/Info.plist index 7a2d856..24afbaa 100644 --- a/ios/KeyJawnKeyboard/Info.plist +++ b/ios/KeyJawnKeyboard/Info.plist @@ -29,7 +29,7 @@ NSExtensionAttributes IsASCIICapable - + PrefersRightToLeft PrimaryLanguage diff --git a/ios/KeyJawnKeyboard/KeyboardViewController.swift b/ios/KeyJawnKeyboard/KeyboardViewController.swift index 647da8f..028f0da 100644 --- a/ios/KeyJawnKeyboard/KeyboardViewController.swift +++ b/ios/KeyJawnKeyboard/KeyboardViewController.swift @@ -1,5 +1,5 @@ -import UIKit import KeyJawnKit +import UIKit public final class KeyboardViewController: UIInputViewController { @@ -9,6 +9,7 @@ public final class KeyboardViewController: UIInputViewController { private var slashPanel: SlashCommandPanel? private var clipboardPanel: ClipboardPanel? private var uploadPanel: UploadPanel? + private var uploadTask: Task? private var theme: KeyboardTheme = .dark @@ -20,11 +21,13 @@ public final class KeyboardViewController: UIInputViewController { private var qwertyTop: NSLayoutConstraint! private var keyboardHeight: NSLayoutConstraint! private var appliedMetrics: KeyboardMetrics? + private var appliedAssistantLeadingInset: CGFloat? // MARK: - Lifecycle public override func viewDidLoad() { super.viewDidLoad() + view.accessibilityIdentifier = "keyjawn.systemKeyboard" theme = KeyboardPrefs.shared.theme view.backgroundColor = theme.keyboardBg setupExtraRow() @@ -59,10 +62,18 @@ public final class KeyboardViewController: UIInputViewController { /// it cannot drive a layout loop. private func applyMetrics() { let metrics = KeyboardMetrics.current(for: traitCollection) - guard metrics != appliedMetrics else { return } + let assistantLeadingInset = KeyboardAssistantLayout.leadingInset( + for: view.bounds.width, + traits: traitCollection + ) + guard metrics != appliedMetrics || assistantLeadingInset != appliedAssistantLeadingInset else { + return + } appliedMetrics = metrics + appliedAssistantLeadingInset = assistantLeadingInset extraRowHeight.constant = metrics.extraRow + extraRow.setAssistantLeadingInset(assistantLeadingInset) numberRowTop.constant = metrics.gap numberRowHeight.constant = metrics.numberRow qwertyTop.constant = metrics.gap @@ -94,8 +105,9 @@ public final class KeyboardViewController: UIInputViewController { numberRow.translatesAutoresizingMaskIntoConstraints = false view.addSubview(numberRow) - numberRowTop = numberRow.topAnchor.constraint(equalTo: extraRow.bottomAnchor, - constant: KeyboardMetrics.phonePortrait.gap) + numberRowTop = numberRow.topAnchor.constraint( + equalTo: extraRow.bottomAnchor, + constant: KeyboardMetrics.phonePortrait.gap) numberRowHeight = numberRow.heightAnchor.constraint(equalToConstant: KeyboardMetrics.phonePortrait.numberRow) NSLayoutConstraint.activate([ numberRowTop, @@ -112,8 +124,9 @@ public final class KeyboardViewController: UIInputViewController { qwerty.translatesAutoresizingMaskIntoConstraints = false view.addSubview(qwerty) - qwertyTop = qwerty.topAnchor.constraint(equalTo: numberRow.bottomAnchor, - constant: KeyboardMetrics.phonePortrait.gap) + qwertyTop = qwerty.topAnchor.constraint( + equalTo: numberRow.bottomAnchor, + constant: KeyboardMetrics.phonePortrait.gap) NSLayoutConstraint.activate([ qwertyTop, qwerty.leadingAnchor.constraint(equalTo: view.leadingAnchor), @@ -258,11 +271,21 @@ extension KeyboardViewController: ExtraRowDelegate { guard uploadPanel == nil else { return } let panel = UploadPanel(theme: theme) - panel.hosts = hasFullAccess ? AppGroupHostStore.shared.hosts : [] + let allHosts = hasFullAccess ? AppGroupHostStore.shared.hosts : [] + let hosts = HostConfig.copiedImageUploadHosts(from: allHosts) + panel.hosts = hosts // Without Full Access the shared container is closed to the extension, so the // host list reads back empty and SFTP has no network. Name that instead of // telling the user to add hosts they have already added. - panel.emptyReason = hasFullAccess ? .noHostsConfigured : .fullAccessRequired + if !hasFullAccess { + panel.emptyReason = .fullAccessRequired + } else if allHosts.isEmpty { + panel.emptyReason = .noHostsConfigured + } else if hosts.isEmpty && allHosts.contains(where: { $0.authMethod == .key }) { + panel.emptyReason = .hostKeyVerificationRequired + } else { + panel.emptyReason = .keyAuthenticationRequired + } panel.onDismiss = { [weak self] in self?.hideUploadPanel() } guard hasFullAccess else { @@ -274,6 +297,15 @@ extension KeyboardViewController: ExtraRowDelegate { return } + guard !hosts.isEmpty else { + panel.statusMessage = panel.emptyReason.message + panel.isUploadEnabled = false + panel.onUpload = { _ in } + showPanel(panel) + uploadPanel = panel + return + } + // Grab the raw encoded image bytes on the main actor (cheap: no decode), // so the heavy decode/downsample/encode can run off it. Reading // UIPasteboard.image here instead would decode the full bitmap on the @@ -282,7 +314,8 @@ extension KeyboardViewController: ExtraRowDelegate { // hasImages true here means an image is present but exposes no // readable data representation, so say that instead of implying the // user copied nothing. - panel.statusMessage = UIPasteboard.general.hasImages + panel.statusMessage = + UIPasteboard.general.hasImages ? "Couldn't read that image. Try copying it again." : "Copy an image first, then tap SCP" panel.isUploadEnabled = false @@ -295,7 +328,7 @@ extension KeyboardViewController: ExtraRowDelegate { // Show the panel immediately in a preparing state with upload gated off, // then downsample and JPEG-encode off the main actor. A host tap before // the data is ready is a no-op (UploadPanel.isUploadEnabled). - panel.statusMessage = "Preparing image..." + panel.statusMessage = "Preparing copied image for remote upload..." panel.isUploadEnabled = false panel.onUpload = { _ in } showPanel(panel) @@ -312,7 +345,7 @@ extension KeyboardViewController: ExtraRowDelegate { panel.statusMessage = "Couldn't read that image. Try copying it again." return } - panel.statusMessage = "Select a host to upload" + panel.statusMessage = "Select a remote SSH host" panel.onUpload = { [weak self] host in self?.performUpload(imageData: imageData, to: host) } @@ -321,6 +354,8 @@ extension KeyboardViewController: ExtraRowDelegate { } private func hideUploadPanel() { + uploadTask?.cancel() + uploadTask = nil uploadPanel?.removeFromSuperview() uploadPanel = nil } @@ -334,19 +369,33 @@ extension KeyboardViewController: ExtraRowDelegate { // A second tap while a transfer is in flight would start a second connection // from a memory-constrained extension; hold the list closed until this returns. uploadPanel?.isUploadEnabled = false - - Task { @MainActor in + guard let panel = uploadPanel else { return } + panel.isDismissEnabled = false + + uploadTask = Task { @MainActor [weak self, weak panel] in + guard let self, let panel else { return } + defer { + if self.uploadPanel === panel { + self.uploadTask = nil + } + } do { let path = try await CitadelSCPUploader.upload( imageData: imageData, to: host, privateKeyData: keyData ) + guard !Task.isCancelled, self.uploadPanel === panel else { return } + self.uploadTask = nil self.textDocumentProxy.insertText(path) self.hideUploadPanel() + } catch is CancellationError { + return } catch { - self.uploadPanel?.statusMessage = "Upload failed: \(error.localizedDescription)" - self.uploadPanel?.isUploadEnabled = true + guard self.uploadPanel === panel else { return } + panel.statusMessage = "Upload failed: \(error.localizedDescription)" + panel.isUploadEnabled = true + panel.isDismissEnabled = true } } } @@ -398,5 +447,3 @@ extension KeyboardViewController: QwertyKeyboardDelegate { advanceToNextInputMode() } } - - diff --git a/ios/KeyJawnKit/Sources/KeyJawnKit/Media/PasteboardImagePreparer.swift b/ios/KeyJawnKit/Sources/KeyJawnKit/Media/PasteboardImagePreparer.swift index 7cf2fd3..6b1b92e 100644 --- a/ios/KeyJawnKit/Sources/KeyJawnKit/Media/PasteboardImagePreparer.swift +++ b/ios/KeyJawnKit/Sources/KeyJawnKit/Media/PasteboardImagePreparer.swift @@ -4,7 +4,7 @@ import ImageIO import UIKit import UniformTypeIdentifiers -/// Prepares a pasteboard image for SCP upload without ever materializing the +/// Prepares a pasteboard image for remote upload without ever materializing the /// full-resolution bitmap. /// /// A keyboard extension runs under a tight jetsam ceiling (roughly 48-70 MB), so diff --git a/ios/KeyJawnKit/Sources/KeyJawnKit/Models/AltKeyMappings.swift b/ios/KeyJawnKit/Sources/KeyJawnKit/Models/AltKeyMappings.swift index d488d03..e156607 100644 --- a/ios/KeyJawnKit/Sources/KeyJawnKit/Models/AltKeyMappings.swift +++ b/ios/KeyJawnKit/Sources/KeyJawnKit/Models/AltKeyMappings.swift @@ -40,7 +40,18 @@ public enum AltKeyMappings { // Shifted symbols for the number row (key = digit label). public static let numberShifts: [String: String] = [ "1": "!", "2": "@", "3": "#", "4": "$", "5": "%", - "6": "^", "7": "&", "8": "*", "9": "(", "0": ")" + "6": "^", "7": "&", "8": "*", "9": "(", "0": ")", + ] + + /// Secondary characters shown on letter keys and inserted by a downward + /// flick. This follows the familiar full-width iPad keyboard layout. + private static let flickTable: [String: String] = [ + "q": "1", "w": "2", "e": "3", "r": "4", "t": "5", + "y": "6", "u": "7", "i": "8", "o": "9", "p": "0", + "a": "@", "s": "#", "d": "$", "f": "%", "g": "&", + "h": "-", "j": "+", "k": "(", "l": ")", + "z": "*", "x": "\"", "c": "'", "v": ":", "b": ";", + "n": "!", "m": "?", ] /// The alternates for a key label, or an empty array when it has none. @@ -55,4 +66,10 @@ public enum AltKeyMappings { } return [] } + + /// The character inserted by a downward flick, or nil when the key does + /// not offer one. Shifted letters use the same secondary character. + public static func flick(for label: String) -> String? { + flickTable[label] ?? flickTable[label.lowercased()] + } } diff --git a/ios/KeyJawnKit/Sources/KeyJawnKit/Models/HostConfig.swift b/ios/KeyJawnKit/Sources/KeyJawnKit/Models/HostConfig.swift index 0a8aafa..c15e942 100644 --- a/ios/KeyJawnKit/Sources/KeyJawnKit/Models/HostConfig.swift +++ b/ios/KeyJawnKit/Sources/KeyJawnKit/Models/HostConfig.swift @@ -1,5 +1,5 @@ -import Foundation import CryptoKit +import Foundation public struct HostConfig: Sendable, Identifiable, Codable, Hashable { public let id: UUID @@ -8,6 +8,9 @@ public struct HostConfig: Sendable, Identifiable, Codable, Hashable { public var port: UInt16 public var username: String public var authMethod: AuthMethod + /// Wraps the SSH stream in TLS for endpoints such as TLS-terminated TCP tunnels. + /// Plain SSH remains the default for normal SSH servers. + public var usesTLSTunnel: Bool /// Server public key in OpenSSH authorized_keys format (e.g. "ssh-ed25519 AAAA..."). /// Obtain with: ssh-keyscan -t ed25519 /// If nil, the main app asks the user to trust the first key it receives. @@ -16,8 +19,8 @@ public struct HostConfig: Sendable, Identifiable, Codable, Hashable { public var uploadPath: String public enum AuthMethod: String, Sendable, Codable, CaseIterable { - case password = "password" - case key = "key" // Ed25519/RSA key stored in Keychain + case password = "password" + case key = "key" // Ed25519/RSA key stored in Keychain } public init( @@ -27,6 +30,7 @@ public struct HostConfig: Sendable, Identifiable, Codable, Hashable { port: UInt16 = 22, username: String, authMethod: AuthMethod = .key, + usesTLSTunnel: Bool = false, hostPublicKey: String? = nil, uploadPath: String = "/tmp" ) { @@ -36,14 +40,52 @@ public struct HostConfig: Sendable, Identifiable, Codable, Hashable { self.port = port self.username = username self.authMethod = authMethod + self.usesTLSTunnel = usesTLSTunnel self.hostPublicKey = hostPublicKey self.uploadPath = uploadPath } + private enum CodingKeys: String, CodingKey { + case id + case label + case hostname + case port + case username + case authMethod + case usesTLSTunnel + case hostPublicKey + case uploadPath + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + label = try container.decode(String.self, forKey: .label) + hostname = try container.decode(String.self, forKey: .hostname) + port = try container.decode(UInt16.self, forKey: .port) + username = try container.decode(String.self, forKey: .username) + authMethod = try container.decode(AuthMethod.self, forKey: .authMethod) + usesTLSTunnel = try container.decodeIfPresent(Bool.self, forKey: .usesTLSTunnel) ?? false + hostPublicKey = try container.decodeIfPresent(String.self, forKey: .hostPublicKey) + uploadPath = try container.decodeIfPresent(String.self, forKey: .uploadPath) ?? "/tmp" + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(label, forKey: .label) + try container.encode(hostname, forKey: .hostname) + try container.encode(port, forKey: .port) + try container.encode(username, forKey: .username) + try container.encode(authMethod, forKey: .authMethod) + try container.encode(usesTLSTunnel, forKey: .usesTLSTunnel) + try container.encodeIfPresent(hostPublicKey, forKey: .hostPublicKey) + try container.encode(uploadPath, forKey: .uploadPath) + } + public var isValid: Bool { - !hostname.trimmingCharacters(in: .whitespaces).isEmpty && - !username.trimmingCharacters(in: .whitespaces).isEmpty && - port > 0 + !hostname.trimmingCharacters(in: .whitespaces).isEmpty && !username.trimmingCharacters(in: .whitespaces).isEmpty + && port > 0 } /// Whether a host key has been stored for strict verification. @@ -69,6 +111,22 @@ public struct HostConfig: Sendable, Identifiable, Codable, Hashable { let portFlag = port == 22 ? "" : "-p \(port) " return "ssh-keyscan \(portFlag)-t ed25519 \(hostname)" } + + /// Returns no command when a TLS relay must receive the first bytes. + /// `ssh-keyscan` sends plain SSH and cannot cross that relay by itself. + public static func hostKeyScanCommand( + hostname: String, + port: UInt16, + usesTLSTunnel: Bool + ) -> String? { + guard !usesTLSTunnel else { return nil } + return hostKeyScanCommand(hostname: hostname, port: port) + } + + /// Hosts that the copied-image uploader can authenticate with and verify. + public static func copiedImageUploadHosts(from hosts: [Self]) -> [Self] { + hosts.filter { $0.authMethod == .key && $0.hasPinnedHostKey } + } } /// The canonical OpenSSH key and SHA-256 fingerprint shown by the first-use prompt. @@ -85,13 +143,16 @@ public struct PresentedHostKey: Sendable, Equatable, Identifiable { whereSeparator: { $0.isWhitespace } ) guard components.count >= 2, - let keyData = Data(base64Encoded: String(components[1])) else { + let keyData = Data(base64Encoded: String(components[1])) + else { throw PresentedHostKeyError.invalidOpenSSHKey } self.openSSHKey = "\(components[0]) \(components[1])" let digest = Data(SHA256.hash(data: keyData)) - self.fingerprint = "SHA256:" + digest.base64EncodedString() + self.fingerprint = + "SHA256:" + + digest.base64EncodedString() .trimmingCharacters(in: CharacterSet(charactersIn: "=")) } diff --git a/ios/KeyJawnKit/Sources/KeyJawnKit/Models/KeyboardDocumentInsert.swift b/ios/KeyJawnKit/Sources/KeyJawnKit/Models/KeyboardDocumentInsert.swift index 4d337bc..20fea9e 100644 --- a/ios/KeyJawnKit/Sources/KeyJawnKit/Models/KeyboardDocumentInsert.swift +++ b/ios/KeyJawnKit/Sources/KeyJawnKit/Models/KeyboardDocumentInsert.swift @@ -52,12 +52,12 @@ public enum KeyboardDocumentInsert: Equatable, Sendable { } /// Armed Ctrl is one-shot only when a control mapping was actually used. - /// A multi-character QWERTY alternate (`..`) inserts unchanged and must - /// leave Ctrl armed for the next letter. + /// An alternate without an ASCII control mapping inserts unchanged and + /// must leave Ctrl armed for the next letter. public static func consumesCtrl(for output: KeyOutput, ctrlActive: Bool) -> Bool { guard ctrlActive else { return false } - if case .character(let text) = output, text.count != 1 { - return false + if case .character = output { + return ANSISequence.bytes(for: output, ctrlActive: true) != nil } switch action(for: output, ctrlActive: true, terminalArrows: true) { case .insert: diff --git a/ios/KeyJawnKit/Sources/KeyJawnKit/Models/KeyboardLayout.swift b/ios/KeyJawnKit/Sources/KeyJawnKit/Models/KeyboardLayout.swift index d25c6d3..bf24421 100644 --- a/ios/KeyJawnKit/Sources/KeyJawnKit/Models/KeyboardLayout.swift +++ b/ios/KeyJawnKit/Sources/KeyJawnKit/Models/KeyboardLayout.swift @@ -40,8 +40,8 @@ public struct Key: Sendable { // // Ctrl+C is first because it's by far the most-used key during LLM sessions // (interrupt generation). The full Ctrl modifier lives on long-press of Ctrl+C. -// Esc replaces Mic — the system keyboard already has a built-in dictation mic, -// and Esc is absent from the iOS keyboard despite being critical in terminal work. +// The extension row uses Esc instead of Mic. The built-in SSH terminal adds its +// own Mic and Send actions because microphone access is available only in the app. public enum ExtraRowSlot: Int, CaseIterable, Sendable { case ctrlC = 0 @@ -85,7 +85,7 @@ public struct ExtraRowKey: Sendable { case .slash: return "Slash commands" case .escape: return "Escape" case .clipboard: return "Clipboard history" - case .upload: return "Upload image over SFTP" + case .upload: return "Upload copied image to remote SSH host" case .send: return "Send" case .letterY: return "y" case .letterN: return "n" @@ -156,9 +156,13 @@ public enum ANSISequence { case .newline: return [0x0a] case .space: return [0x20] case .character(let s): - guard let scalar = s.unicodeScalars.first else { return nil } - let byte = UInt8(scalar.value & 0xFF) - return ctrlActive ? [byte & 0x1f] : [byte] + guard !s.isEmpty else { return nil } + guard ctrlActive else { return Array(s.utf8) } + guard s.unicodeScalars.count == 1, + let scalar = s.unicodeScalars.first, + scalar.isASCII + else { return nil } + return [UInt8(scalar.value) & 0x1f] case .slash: return nil // handled by slash command popup } } diff --git a/ios/KeyJawnKit/Sources/KeyJawnKit/Models/KeyboardMetrics.swift b/ios/KeyJawnKit/Sources/KeyJawnKit/Models/KeyboardMetrics.swift index 24f10e5..09bff83 100644 --- a/ios/KeyJawnKit/Sources/KeyJawnKit/Models/KeyboardMetrics.swift +++ b/ios/KeyJawnKit/Sources/KeyJawnKit/Models/KeyboardMetrics.swift @@ -25,3 +25,23 @@ public struct KeyboardMetrics: Equatable, Sendable { } #endif } + +/// Horizontal space used by the iPad system assistant controls. +/// +/// Some host apps place undo, redo, and paste over a third-party keyboard's +/// leading edge. Full-width iPad layouts have enough room to reserve that area +/// without shrinking terminal controls below usable touch targets. Compact +/// split views and phones keep the complete row width. +public enum KeyboardAssistantLayout { + /// Ten 44-point controls, nine 4-point gaps, 12 points of outer padding, and + /// the 152-point assistant reservation require at least 640 points. + public static let fullWidthPadMinimum: CGFloat = 640 + public static let leadingInset: CGFloat = 152 + + #if canImport(UIKit) + public static func leadingInset(for width: CGFloat, traits: UITraitCollection) -> CGFloat { + guard traits.userInterfaceIdiom == .pad, width >= fullWidthPadMinimum else { return 0 } + return leadingInset + } + #endif +} diff --git a/ios/KeyJawnKit/Sources/KeyJawnKit/Models/KeyboardTheme.swift b/ios/KeyJawnKit/Sources/KeyJawnKit/Models/KeyboardTheme.swift index d2ec710..1f48695 100644 --- a/ios/KeyJawnKit/Sources/KeyJawnKit/Models/KeyboardTheme.swift +++ b/ios/KeyJawnKit/Sources/KeyJawnKit/Models/KeyboardTheme.swift @@ -69,6 +69,10 @@ public enum KeyboardTheme: String, CaseIterable, Sendable { } } + /// Smaller secondary glyph shown on iPad letter keys. The alpha is + /// composited over `keyBg`; contrast tests enforce the rendered result. + public var flickKeyText: UIColor { keyText.withAlphaComponent(0.65) } + public var armed: UIColor { switch self { case .dark, .light, .oled: diff --git a/ios/KeyJawnKit/Sources/KeyJawnKit/Models/OnboardingCopy.swift b/ios/KeyJawnKit/Sources/KeyJawnKit/Models/OnboardingCopy.swift index 8cd6b79..f416550 100644 --- a/ios/KeyJawnKit/Sources/KeyJawnKit/Models/OnboardingCopy.swift +++ b/ios/KeyJawnKit/Sources/KeyJawnKit/Models/OnboardingCopy.swift @@ -25,11 +25,11 @@ public enum OnboardingCopy: Sendable { public static let whatItIs = Page( title: "A keyboard for CLI agents", body: """ - KeyJawn is a phone keyboard with the extra keys a CLI agent session needs: Esc, Tab, Ctrl, arrows, and text shortcuts. + KeyJawn is a mobile keyboard with the extra keys a CLI agent session needs: Esc, Tab, Ctrl, arrows, and text shortcuts. Slash shortcuts insert plain text into the field that has focus. They are not links to other apps. - This app also has a built-in SSH terminal so those keys can reach a shell on iOS, where a keyboard extension cannot send them as real key events. + This app also has a built-in SSH terminal. It sends your input to a remote SSH server. Commands run on that server, not on your iPhone or iPad. KeyJawn displays the returned output and cannot browse files on your iPhone or iPad. """ ) @@ -39,7 +39,7 @@ public enum OnboardingCopy: Sendable { 1. Open Settings, then General, then Keyboard, then Keyboards. 2. Tap Add new keyboard and choose KeyJawn Keyboard. 3. Basic typing works without Full Access. - 4. Grant Full Access only if you want themes, clipboard history, and image upload to read shared settings. + 4. Grant Full Access only if you want KeyJawn Keyboard to use network access for remote image upload, use user-created shortcuts, or read shared settings and clipboard history. The keyboard cannot open that screen for you. Use Open Settings below, then follow the steps. Full Access is a system permission, not something KeyJawn can grant itself. """ @@ -48,7 +48,7 @@ public enum OnboardingCopy: Sendable { public static let addAHost = Page( title: "Add a host and copy your key", body: """ - After this screen, use the Hosts tab to add an SSH server. + After this screen, use the Hosts tab to add a remote SSH server. Settings, then SSH keys, shows the public key to paste into authorized_keys on that server. The first connection asks you to pin the host key fingerprint. """ diff --git a/ios/KeyJawnKit/Sources/KeyJawnKit/Models/TerminalInputMapping.swift b/ios/KeyJawnKit/Sources/KeyJawnKit/Models/TerminalInputMapping.swift index 28baad3..244914f 100644 --- a/ios/KeyJawnKit/Sources/KeyJawnKit/Models/TerminalInputMapping.swift +++ b/ios/KeyJawnKit/Sources/KeyJawnKit/Models/TerminalInputMapping.swift @@ -5,7 +5,7 @@ import Foundation /// `TerminalInputView.insertText` and the extra-row Send key both call through /// here so a test of this type is a test of the shipped submit path, not a copy /// of it. A lone Return is CR (`0x0d`) because that is what a PTY treats as -/// submit. A dedicated newline helper writes LF (`0x0a`) so a multiline prompt +/// submit. The newline mapping writes LF (`0x0a`) so a multiline prompt /// can gain a line without submitting. public enum TerminalInputMapping: Sendable { public static let submitBytes: [UInt8] = [0x0d] diff --git a/ios/KeyJawnKit/Sources/KeyJawnKit/Views/ExtraRowView.swift b/ios/KeyJawnKit/Sources/KeyJawnKit/Views/ExtraRowView.swift index 0a925c8..11c0eee 100644 --- a/ios/KeyJawnKit/Sources/KeyJawnKit/Views/ExtraRowView.swift +++ b/ios/KeyJawnKit/Sources/KeyJawnKit/Views/ExtraRowView.swift @@ -35,6 +35,7 @@ public final class ExtraRowView: UIView { // MARK: Private private let stack = UIStackView() + private var stackLeading: NSLayoutConstraint! private var ctrlCButton: ExtraRowButton? private var micButton: ExtraRowButton? private var repeatTimer: Timer? @@ -77,8 +78,9 @@ public final class ExtraRowView: UIView { stack.translatesAutoresizingMaskIntoConstraints = false addSubview(stack) + stackLeading = stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 6) NSLayoutConstraint.activate([ - stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 6), + stackLeading, stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -6), stack.topAnchor.constraint(equalTo: topAnchor, constant: 6), stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -6), @@ -91,6 +93,12 @@ public final class ExtraRowView: UIView { } } + /// Keep interactive keys clear of the iPad system assistant controls. + /// The controller supplies zero for phones and compact split views. + public func setAssistantLeadingInset(_ inset: CGFloat) { + stackLeading.constant = 6 + max(0, inset) + } + /// Replace the row's keys. Used by the SSH accessory to add Send and by /// remappable presets later. Rebuilds buttons; Ctrl visual state is reapplied. public func setKeys(_ keys: [ExtraRowKey]) { @@ -264,15 +272,21 @@ public final class ExtraRowView: UIView { UIView.animate(withDuration: 0.15) { [self] in switch state { case .off: + btn.setTitle(btn.key.label, for: .normal) + btn.accessibilityValue = "Off" btn.backgroundColor = keyBg btn.layer.shadowOpacity = 0 case .armed: + btn.setTitle("\(btn.key.label)·", for: .normal) + btn.accessibilityValue = "Armed for next key" btn.backgroundColor = armed btn.layer.shadowColor = armed.withAlphaComponent(0.5).cgColor btn.layer.shadowOpacity = 1 btn.layer.shadowRadius = 8 btn.layer.shadowOffset = .zero case .locked: + btn.setTitle("\(btn.key.label)∞", for: .normal) + btn.accessibilityValue = "Locked" btn.backgroundColor = locked btn.layer.shadowColor = locked.withAlphaComponent(0.5).cgColor btn.layer.shadowOpacity = 1 diff --git a/ios/KeyJawnKit/Sources/KeyJawnKit/Views/QwertyKeyboardView.swift b/ios/KeyJawnKit/Sources/KeyJawnKit/Views/QwertyKeyboardView.swift index 4ad0e3e..14a2294 100644 --- a/ios/KeyJawnKit/Sources/KeyJawnKit/Views/QwertyKeyboardView.swift +++ b/ios/KeyJawnKit/Sources/KeyJawnKit/Views/QwertyKeyboardView.swift @@ -23,7 +23,7 @@ public extension QwertyKeyboardDelegate { // MARK: - QwertyKeyboardView /// Full QWERTY keyboard view for use inside a keyboard extension. -/// Manages three layers (lowercase, uppercase, symbols) and shift state. +/// Manages four layers (lowercase, uppercase, and two symbol pages) and shift state. /// Does NOT include the extra row — compose that separately above this view. @MainActor public final class QwertyKeyboardView: UIView { @@ -37,7 +37,7 @@ public final class QwertyKeyboardView: UIView { // MARK: Theme - public var theme: KeyboardTheme = .dark + public private(set) var theme: KeyboardTheme = .dark private var bg: UIColor { theme.keyboardBg } private var keyBg: UIColor { theme.keyBg } @@ -47,7 +47,7 @@ public final class QwertyKeyboardView: UIView { // MARK: Layout constants private let spacingH: CGFloat = 6 - private let spacingV: CGFloat = 11 + private let spacingV: CGFloat = 7 private let sidePad: CGFloat = 3 private let topPad: CGFloat = 8 private let bottomPad: CGFloat = 4 @@ -57,10 +57,10 @@ public final class QwertyKeyboardView: UIView { private var keyButtons: [QwertyKeyButton] = [] // The theme the current buttons were built under. Each QwertyKeyButton captures // its theme at init for title and tint colours, so the in-place fast path is only - // valid while this matches the view's theme. It diverges when theme is set - // directly (the keyboard extension does qwerty.theme = ... rather than going - // through applyTheme), and the fast path must fall back to a full rebuild then. + // valid while this matches the view's theme. `applyTheme` changes the theme and + // makes the fast path fall back to a full rebuild. private var builtTheme: KeyboardTheme? + private var flicksEnabled: Bool { UIDevice.current.userInterfaceIdiom == .pad } // MARK: - Init @@ -104,8 +104,9 @@ public final class QwertyKeyboardView: UIView { keyButtons.count == newKeys.count, zip(keyButtons, newKeys).allSatisfy({ $0.key.structuralKind == $1.structuralKind }) { for (btn, key) in zip(keyButtons, newKeys) { - btn.apply(key: key) + btn.apply(key: key, showsFlick: flicksEnabled) btn.backgroundColor = bgColor(for: key) + configureFlickAccessibility(for: btn) } return } @@ -114,14 +115,20 @@ public final class QwertyKeyboardView: UIView { keyButtons.forEach { $0.removeFromSuperview() } keyButtons.removeAll() for key in newKeys { - let btn = QwertyKeyButton(key: key, theme: theme) + let btn = QwertyKeyButton(key: key, theme: theme, showsFlick: flicksEnabled) btn.backgroundColor = bgColor(for: key) btn.addTarget(self, action: #selector(keyTapped(_:)), for: .touchUpInside) switch key { - case .character: + case .character(let label): let lp = UILongPressGestureRecognizer(target: self, action: #selector(keyLongPressed(_:))) lp.minimumPressDuration = 0.4 btn.addGestureRecognizer(lp) + if flicksEnabled, AltKeyMappings.flick(for: label) != nil { + let flick = UISwipeGestureRecognizer(target: self, action: #selector(keyFlicked(_:))) + flick.direction = .down + flick.cancelsTouchesInView = true + btn.addGestureRecognizer(flick) + } case .backspace: // Holding backspace did nothing at all before this — every deletion // cost a separate tap, which is punishing on a keyboard whose whole @@ -134,6 +141,7 @@ public final class QwertyKeyboardView: UIView { default: break } + configureFlickAccessibility(for: btn) addSubview(btn) keyButtons.append(btn) } @@ -279,13 +287,7 @@ public final class QwertyKeyboardView: UIView { switch sender.key { case .character(let s): - delegate?.keyboard(self, insertText: s) - if shiftState == .once { - shiftState = .off - layer_ = .lowercase - rebuild() - setNeedsLayout() - } + insertCharacter(s) case .space: delegate?.keyboard(self, insertText: " ") @@ -386,6 +388,52 @@ public final class QwertyKeyboardView: UIView { // MARK: - Long-press alt characters + private func insertCharacter(_ text: String) { + delegate?.keyboard(self, insertText: text) + if shiftState == .once { + shiftState = .off + layer_ = .lowercase + rebuild() + setNeedsLayout() + } + } + + /// VoiceOver cannot perform the touch-only downward swipe. Give it the same + /// insertion path so shift consumption and delegate behavior stay identical. + private func configureFlickAccessibility(for button: QwertyKeyButton) { + guard flicksEnabled, + case .character(let label) = button.key, + let secondary = AltKeyMappings.flick(for: label) + else { + button.accessibilityCustomActions = nil + return + } + + button.accessibilityCustomActions = [ + UIAccessibilityCustomAction(name: "Insert \(secondary)") { [weak self, weak button] _ in + guard let self, let button, + case .character(let currentLabel) = button.key, + let currentSecondary = AltKeyMappings.flick(for: currentLabel) + else { return false } + + KeyboardHaptics.keyPress() + self.insertCharacter(currentSecondary) + return true + } + ] + } + + @objc private func keyFlicked(_ gr: UISwipeGestureRecognizer) { + guard gr.state == .ended, + let btn = gr.view as? QwertyKeyButton, + case .character(let label) = btn.key, + let secondary = AltKeyMappings.flick(for: label) + else { return } + + KeyboardHaptics.keyPress() + insertCharacter(secondary) + } + @objc private func keyLongPressed(_ gr: UILongPressGestureRecognizer) { guard gr.state == .began, let btn = gr.view as? QwertyKeyButton, @@ -478,6 +526,7 @@ final class QwertyKeyButton: UIButton { private(set) var key: QwertyKey private let theme: KeyboardTheme + private let flickLabel = UILabel() // Resolve the three SF Symbols once per process rather than on every rebuild: // UIImage(systemName:) is a non-trivial lookup and these images never change. @@ -485,12 +534,12 @@ final class QwertyKeyButton: UIButton { private static let shiftImage = UIImage(systemName: "shift") private static let globeImage = UIImage(systemName: "globe") - init(key: QwertyKey, theme: KeyboardTheme) { + init(key: QwertyKey, theme: KeyboardTheme, showsFlick: Bool) { self.key = key self.theme = theme super.init(frame: .zero) configureChrome() - apply(key: key) + apply(key: key, showsFlick: showsFlick) } required init?(coder: NSCoder) { fatalError("use init(key:theme:)") } @@ -502,6 +551,12 @@ final class QwertyKeyButton: UIButton { let text = theme.keyText setTitleColor(text, for: .normal) setTitleColor(text.withAlphaComponent(0.4), for: .highlighted) + flickLabel.font = .systemFont(ofSize: 10, weight: .medium) + flickLabel.textAlignment = .right + flickLabel.textColor = theme.flickKeyText + flickLabel.isUserInteractionEnabled = false + flickLabel.isAccessibilityElement = false + addSubview(flickLabel) layer.cornerRadius = 5 layer.masksToBounds = false layer.shadowColor = UIColor.black.cgColor @@ -514,10 +569,13 @@ final class QwertyKeyButton: UIButton { // a reused button, which QwertyKeyboardView does on a lowercase<->uppercase // toggle. Both the title and the image are cleared first so no stale glyph // survives even if a caller ever reuses a button across kinds. - func apply(key: QwertyKey) { + func apply(key: QwertyKey, showsFlick: Bool) { self.key = key setTitle(nil, for: .normal) setImage(nil, for: .normal) + flickLabel.text = nil + flickLabel.isHidden = true + accessibilityHint = nil // The image keys carry no title for VoiceOver to read, and "#+=" and "123" // are announced character by character. Name each one instead. accessibilityLabel = key.spokenName @@ -526,6 +584,11 @@ final class QwertyKeyButton: UIButton { case .character(let s): titleLabel?.font = .systemFont(ofSize: 17, weight: .light) setTitle(s, for: .normal) + if showsFlick, let secondary = AltKeyMappings.flick(for: s) { + flickLabel.text = secondary + flickLabel.isHidden = false + accessibilityHint = "Swipe down for \(secondary)" + } case .space: titleLabel?.font = .systemFont(ofSize: 15, weight: .regular) @@ -561,6 +624,11 @@ final class QwertyKeyButton: UIButton { } } + override func layoutSubviews() { + super.layoutSubviews() + flickLabel.frame = CGRect(x: bounds.width - 18, y: 2, width: 14, height: 12) + } + override var isHighlighted: Bool { didSet { UIView.animate(withDuration: 0.06) { diff --git a/ios/KeyJawnKit/Sources/KeyJawnKit/Views/UploadPanel.swift b/ios/KeyJawnKit/Sources/KeyJawnKit/Views/UploadPanel.swift index e5e4e71..7495339 100644 --- a/ios/KeyJawnKit/Sources/KeyJawnKit/Views/UploadPanel.swift +++ b/ios/KeyJawnKit/Sources/KeyJawnKit/Views/UploadPanel.swift @@ -27,13 +27,22 @@ public final class UploadPanel: UIView { public enum EmptyReason { case noHostsConfigured case fullAccessRequired + case keyAuthenticationRequired + case hostKeyVerificationRequired - var message: String { + public var message: String { switch self { case .noHostsConfigured: - return "No hosts configured. Add one in the main app." + return "No remote SSH hosts configured. Add an SSH host before upload." case .fullAccessRequired: - return "Turn on Full Access to reach your hosts: Settings → General → Keyboard → Keyboards → KeyJawn." + return + "Turn on Full Access to reach remote SSH hosts: Settings → General → Keyboard → Keyboards → KeyJawn." + case .keyAuthenticationRequired: + return + "Copied-image upload requires a host that uses SSH key authentication. Configure one before upload." + case .hostKeyVerificationRequired: + return + "Open the SSH-key host in KeyJawn once to verify its SSH host key before upload." } } } @@ -47,6 +56,15 @@ public final class UploadPanel: UIView { didSet { tableView.alpha = isUploadEnabled ? 1.0 : 0.5 } } + /// Cancel is available while choosing a host, but not after the remote + /// write starts because a network close cannot prove that no bytes landed. + public var isDismissEnabled: Bool = true { + didSet { + cancelButton.isEnabled = isDismissEnabled + cancelButton.alpha = isDismissEnabled ? 1.0 : 0.5 + } + } + public init(theme: KeyboardTheme = .dark) { self.theme = theme super.init(frame: .zero) @@ -66,7 +84,7 @@ public final class UploadPanel: UIView { toolbar.translatesAutoresizingMaskIntoConstraints = false addSubview(toolbar) - titleLabel.text = "SCP upload" + titleLabel.text = "Upload copied image" titleLabel.font = .systemFont(ofSize: 15, weight: .semibold) titleLabel.textColor = theme.panelText titleLabel.translatesAutoresizingMaskIntoConstraints = false diff --git a/ios/Shared/AppleNetworkSSHTransport.swift b/ios/Shared/AppleNetworkSSHTransport.swift new file mode 100644 index 0000000..65239e6 --- /dev/null +++ b/ios/Shared/AppleNetworkSSHTransport.swift @@ -0,0 +1,297 @@ +@preconcurrency import Citadel +import Foundation +import NIOCore +import NIOTransportServices +import Network +import OSLog + +/// Carries Citadel's immutable authentication delegate into one detached task +/// without changing the third-party type's global concurrency contract. +struct AppleNetworkSSHAuthentication: @unchecked Sendable { + let method: SSHAuthenticationMethod + + init(_ method: SSHAuthenticationMethod) { + self.method = method + } +} + +enum AppleNetworkSSHTransportError: Error, LocalizedError, Sendable, Equatable { + case connectionFailed + case authenticationFailed + case sshHandshakeFailed + case operationTimedOut + + var errorDescription: String? { + switch self { + case .connectionFailed: + return + "Could not connect to the SSH server. Check the host, port, TLS tunnel setting, and network connection." + case .authenticationFailed: + return "SSH authentication failed. Check the username and password or SSH key." + case .sshHandshakeFailed: + return "The SSH server closed the connection before setup finished. Check the server and host key." + case .operationTimedOut: + return "The SSH operation timed out. Check the server and network connection, then try again." + } + } +} + +/// Opens Citadel SSH clients through Apple's Network.framework transport. +/// +/// NIO's POSIX bootstrap can remain pending on a physical iOS device even when +/// Network.framework can reach the same endpoint. NIOTS preserves Citadel's NIO +/// channel pipeline while using the system network path for DNS, VPN, and TCP. +enum AppleNetworkSSHTransport { + struct Connection { + let client: SSHClient + let channel: Channel + } + + private static let group = NIOTSEventLoopGroup.singleton + private static let logger = Logger( + subsystem: "com.keyjawn", + category: "SSH transport" + ) + + static func bootstrap( + useTLS: Bool, + connectTimeout: TimeAmount = .seconds(30) + ) -> NIOTSConnectionBootstrap { + let bootstrap = NIOTSConnectionBootstrap(group: group) + .connectTimeout(connectTimeout) + if useTLS { + return bootstrap.tlsOptions(NWProtocolTLS.Options()) + } + return bootstrap + } + + static func connect( + host: String, + port: Int, + useTLS: Bool, + authentication: AppleNetworkSSHAuthentication, + hostKeyValidator: SSHHostKeyValidator + ) async throws -> Connection { + let channelBox = AppleNetworkChannelBox() + + return try await withTaskCancellationHandler { + do { + let channel: Channel + do { + channel = try await bootstrap(useTLS: useTLS) + .channelInitializer { channel in + // Capture the channel before DNS, TCP, or TLS setup + // finishes so task cancellation can close pending work. + channelBox.store(channel) + return channel.eventLoop.makeSucceededVoidFuture() + } + .connect(host: host, port: port) + .get() + } catch { + try Task.checkCancellation() + throw connectionError(error) + } + try Task.checkCancellation() + + let settings = SSHClientSettings( + host: host, + port: port, + authenticationMethod: { authentication.method }, + hostKeyValidator: hostKeyValidator + ) + // Citadel 0.12 does not expose a reconnect setting for an adopted + // channel. Its adopted-channel client defaults to no reconnect. + // Keep this path because its host-and-port overload creates a POSIX + // bootstrap and would bypass Network.framework and optional TLS. + let deadline = AppleNetworkOperationDeadline( + channel: channel, + timeout: .seconds(30) + ) + + let client: SSHClient + do { + client = try await SSHClient.connect(on: channel, settings: settings) + } catch { + if Task.isCancelled { + deadline.cancel() + throw CancellationError() + } + guard deadline.complete() else { + throw AppleNetworkSSHTransportError.operationTimedOut + } + throw userFacingError(error) + } + if Task.isCancelled { + deadline.cancel() + throw CancellationError() + } + guard deadline.complete() else { + throw AppleNetworkSSHTransportError.operationTimedOut + } + return Connection(client: client, channel: channel) + } catch { + channelBox.close() + throw error + } + } onCancel: { + channelBox.close() + } + } + + static func connectionError(_ error: Error) -> AppleNetworkSSHTransportError { + if let channelError = error as? ChannelError, + case .connectTimeout = channelError + { + return .operationTimedOut + } + logPrivateFailure(error, stage: "TCP or TLS connection") + return .connectionFailed + } + + /// Preserves security and cancellation errors while hiding library details + /// that do not help a user repair a connection. + static func userFacingError(_ error: Error) -> Error { + if error is InvalidHostKey || error is CancellationError { + return error + } + if let transportError = error as? AppleNetworkSSHTransportError { + return transportError + } + if error is AuthenticationFailed { + return AppleNetworkSSHTransportError.authenticationFailed + } + if let clientError = error as? SSHClientError { + switch clientError { + case .allAuthenticationOptionsFailed, + .unsupportedPasswordAuthentication, + .unsupportedPrivateKeyAuthentication, + .unsupportedHostBasedAuthentication: + return AppleNetworkSSHTransportError.authenticationFailed + case .channelCreationFailed: + break + } + } + if let channelError = error as? ChannelError, + case .connectTimeout = channelError + { + return AppleNetworkSSHTransportError.operationTimedOut + } + + logPrivateFailure(error, stage: "SSH setup") + return AppleNetworkSSHTransportError.sshHandshakeFailed + } + + private static func logPrivateFailure(_ error: Error, stage: StaticString) { + logger.error( + "\(stage): \(String(describing: type(of: error)), privacy: .private)" + ) + } +} + +/// Closes a channel if one bounded SSH operation does not finish in time. +/// +/// Closing the channel cancels the underlying NIO operation. This is more direct +/// than racing a Swift task against a timer because Citadel's client is not +/// Sendable and must remain in the task that owns it. +final class AppleNetworkOperationDeadline: @unchecked Sendable { + private let state: State + private let scheduled: Scheduled + + init(channel: Channel, timeout: TimeAmount) { + let state = State() + self.state = state + scheduled = channel.eventLoop.scheduleTask(in: timeout) { + guard state.markTimedOut() else { return } + channel.close(promise: nil) + } + } + + var didTimeOut: Bool { + state.didTimeOut + } + + func cancel() { + _ = state.complete() + scheduled.cancel() + } + + /// Atomically marks the operation complete before the timer wins. + /// Returns false when the timeout already closed the channel. + @discardableResult + func complete() -> Bool { + let completedBeforeTimeout = state.complete() + scheduled.cancel() + return completedBeforeTimeout + } + + private final class State: @unchecked Sendable { + private let lock = NSLock() + private var isActive = true + private var timedOut = false + + var didTimeOut: Bool { + lock.lock() + defer { lock.unlock() } + return timedOut + } + + func markTimedOut() -> Bool { + lock.lock() + defer { lock.unlock() } + guard isActive else { return false } + isActive = false + timedOut = true + return true + } + + func complete() -> Bool { + lock.lock() + defer { lock.unlock() } + guard isActive else { return false } + isActive = false + return true + } + } +} + +/// Moves channel closure into `@Sendable` cancellation handlers without +/// changing NIO's global concurrency contract. +final class AppleNetworkChannelCloser: @unchecked Sendable { + private let channel: Channel + + init(channel: Channel) { + self.channel = channel + } + + func close() { + channel.close(promise: nil) + } +} + +/// Retains a channel while an async connection is in progress. Cancellation +/// can race channel creation, so close requests are remembered. +final class AppleNetworkChannelBox: @unchecked Sendable { + private let lock = NSLock() + private var channel: Channel? + private var closeRequested = false + + func store(_ channel: Channel) { + lock.lock() + if closeRequested { + lock.unlock() + channel.close(promise: nil) + } else { + self.channel = channel + lock.unlock() + } + } + + func close() { + lock.lock() + closeRequested = true + let channel = channel + self.channel = nil + lock.unlock() + channel?.close(promise: nil) + } +} diff --git a/ios/Shared/CitadelSCPUploader.swift b/ios/Shared/CitadelSCPUploader.swift index c69806d..1a3e944 100644 --- a/ios/Shared/CitadelSCPUploader.swift +++ b/ios/Shared/CitadelSCPUploader.swift @@ -1,9 +1,10 @@ -import Foundation import Citadel -import NIOCore -@preconcurrency import NIOSSH import CryptoKit +import Foundation import KeyJawnKit +import NIOCore +@preconcurrency import NIOSSH +import OSLog /// Uploads image data to a remote host via SFTP using Citadel. /// Compiled into both the app and the keyboard extension so the in-app extra @@ -11,35 +12,52 @@ import KeyJawnKit /// the kit must stay free of the Citadel dependency. enum CitadelSCPUploader { - enum UploadError: Error, LocalizedError { - case noHosts + enum UploadError: Error, LocalizedError, Equatable { + case keyAuthenticationRequired case hostKeyNotTrusted + case hostKeyChanged case invalidHostKey case invalidPrivateKey - case connectionFailed(Error) - case uploadFailed(Error) + case authenticationFailed + case connectionFailed + case uploadTimedOut + case uploadFailed var errorDescription: String? { switch self { - case .noHosts: return "No hosts configured" - case .hostKeyNotTrusted: return "Open this host in KeyJawn once to verify its SSH host key" - case .invalidHostKey: return "The saved SSH host key is invalid" - case .invalidPrivateKey: return "SSH key is invalid" - case .connectionFailed(let e): return "Connection failed: \(e.localizedDescription)" - case .uploadFailed(let e): return "Upload failed: \(e.localizedDescription)" + case .keyAuthenticationRequired: return "Copied-image upload requires SSH key authentication" + case .hostKeyNotTrusted: return "Open this host in KeyJawn once to verify its SSH host key" + case .hostKeyChanged: return "The SSH server host key changed. Verify the server in KeyJawn" + case .invalidHostKey: return "The saved SSH host key is invalid" + case .invalidPrivateKey: return "SSH key is invalid" + case .authenticationFailed: return "SSH key authentication failed" + case .connectionFailed: return "Could not connect to the SSH server" + case .uploadTimedOut: return "Copied-image upload timed out" + case .uploadFailed: return "Copied-image upload failed" } } } + private static let logger = Logger( + subsystem: "com.keyjawn", + category: "SFTP upload" + ) + /// Uploads `imageData` to `host` via SFTP. Returns the remote file path on success. static func upload(imageData: Data, to host: HostConfig, privateKeyData: Data) async throws -> String { + // The uploader receives only the shared SSH private key. Password hosts work + // in the terminal, but this path has no password credential it could use. + guard host.authMethod == .key else { + throw UploadError.keyAuthenticationRequired + } // The extension cannot present a trust prompt. Refuse before parsing the // private key or opening a socket, and direct the user to the main app. guard host.hasPinnedHostKey else { throw UploadError.hostKeyNotTrusted } guard let hostKeyString = host.hostPublicKey, - let hostKey = try? NIOSSHPublicKey(openSSHPublicKey: hostKeyString) else { + let hostKey = try? NIOSSHPublicKey(openSSHPublicKey: hostKeyString) + else { throw UploadError.invalidHostKey } let validator = SSHHostKeyValidator.trustedKeys(Set([hostKey])) @@ -65,35 +83,101 @@ enum CitadelSCPUploader { let authMethod = SSHAuthenticationMethod.ed25519(username: host.username, privateKey: privateKey) // Connect. - let client: SSHClient + let connection: AppleNetworkSSHTransport.Connection do { - client = try await SSHClient.connect( + connection = try await AppleNetworkSSHTransport.connect( host: host.hostname, port: Int(host.port), - authenticationMethod: authMethod, - hostKeyValidator: validator, - reconnect: .never + useTLS: host.usesTLSTunnel, + authentication: AppleNetworkSSHAuthentication(authMethod), + hostKeyValidator: validator ) } catch { - throw UploadError.connectionFailed(error) + throw connectionFailure(from: error) } + let client = connection.client + defer { connection.channel.close(promise: nil) } + let channelCloser = AppleNetworkChannelCloser( + channel: connection.channel + ) + let deadline = AppleNetworkOperationDeadline( + channel: connection.channel, + timeout: .seconds(30) + ) // Upload via SFTP. do { - try await client.withSFTP { sftp in - try await sftp.withFile( - filePath: remotePath, - flags: [.write, .create, .truncate] - ) { file in - try await file.write(ByteBuffer(data: imageData)) + try await withTaskCancellationHandler { + try await client.withSFTP { sftp in + try await sftp.withFile( + filePath: remotePath, + flags: [.write, .create, .truncate] + ) { file in + try await file.write(ByteBuffer(data: imageData)) + } } + } onCancel: { + channelCloser.close() + } + if Task.isCancelled { + deadline.cancel() + throw CancellationError() + } + guard deadline.complete() else { + throw UploadError.uploadTimedOut } } catch { - try? await client.close() - throw UploadError.uploadFailed(error) + let completedBeforeDeadline = deadline.complete() + if Task.isCancelled { + throw CancellationError() + } + if let uploadError = error as? UploadError { + throw uploadError + } + if !completedBeforeDeadline { + throw UploadError.uploadTimedOut + } + throw uploadFailure( + from: error, + didTimeOut: false + ) } - try? await client.close() return remotePath } + + static func connectionFailure(from error: Error) -> Error { + if error is CancellationError { + return error + } + if error is InvalidHostKey { + return UploadError.hostKeyChanged + } + let presentedError = AppleNetworkSSHTransport.userFacingError(error) + if presentedError as? AppleNetworkSSHTransportError == .authenticationFailed { + return UploadError.authenticationFailed + } + if presentedError as? AppleNetworkSSHTransportError == .operationTimedOut { + return UploadError.uploadTimedOut + } + return UploadError.connectionFailed + } + + static func uploadFailure(from error: Error, didTimeOut: Bool) -> Error { + if error is CancellationError { + return error + } + if didTimeOut { + return UploadError.uploadTimedOut + } + if let sftpError = error as? SFTPError, + case .missingResponse = sftpError + { + return UploadError.uploadTimedOut + } + logger.error( + "Upload failed: \(String(describing: type(of: error)), privacy: .private)" + ) + return UploadError.uploadFailed + } } diff --git a/ios/Shared/PrivacyInfo.xcprivacy b/ios/Shared/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..8e47c32 --- /dev/null +++ b/ios/Shared/PrivacyInfo.xcprivacy @@ -0,0 +1,18 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + 1C8F.1 + + + + + diff --git a/ios/Tests/ANSISequenceTests.swift b/ios/Tests/ANSISequenceTests.swift index 36949f4..3be475f 100644 --- a/ios/Tests/ANSISequenceTests.swift +++ b/ios/Tests/ANSISequenceTests.swift @@ -47,6 +47,11 @@ final class ANSISequenceTests: XCTestCase { XCTAssertEqual(ANSISequence.bytes(for: .character("x")), [0x78]) } + func testNonASCIICharacterUsesUTF8AndRejectsControlMasking() { + XCTAssertEqual(ANSISequence.bytes(for: .character("é")), Array("é".utf8)) + XCTAssertNil(ANSISequence.bytes(for: .character("é"), ctrlActive: true)) + } + func testSlashHasNoBytesBecauseThePanelHandlesIt() { XCTAssertNil(ANSISequence.bytes(for: .slash)) XCTAssertNil(ANSISequence.text(for: .slash)) diff --git a/ios/Tests/AltKeyMappingsTests.swift b/ios/Tests/AltKeyMappingsTests.swift index 7b41c28..5d652c8 100644 --- a/ios/Tests/AltKeyMappingsTests.swift +++ b/ios/Tests/AltKeyMappingsTests.swift @@ -59,6 +59,29 @@ final class AltKeyMappingsTests: XCTestCase { XCTAssertEqual(AltKeyMappings.numberShifts["0"], ")") } + // MARK: - iPad-style downward flicks + + func testLetterFlicksMatchTheStandardIPadLayout() { + XCTAssertEqual(AltKeyMappings.flick(for: "q"), "1") + XCTAssertEqual(AltKeyMappings.flick(for: "p"), "0") + XCTAssertEqual(AltKeyMappings.flick(for: "a"), "@") + XCTAssertEqual(AltKeyMappings.flick(for: "l"), ")") + XCTAssertEqual(AltKeyMappings.flick(for: "z"), "*") + XCTAssertEqual(AltKeyMappings.flick(for: "m"), "?") + } + + func testUppercaseUsesTheSameFlickCharacters() { + XCTAssertEqual(AltKeyMappings.flick(for: "Q"), "1") + XCTAssertEqual(AltKeyMappings.flick(for: "A"), "@") + XCTAssertEqual(AltKeyMappings.flick(for: "M"), "?") + } + + func testEveryLetterKeyHasOneFlickCharacter() { + for letter in "abcdefghijklmnopqrstuvwxyz" { + XCTAssertNotNil(AltKeyMappings.flick(for: String(letter)), "\(letter) has no flick character") + } + } + // MARK: - Invariants func testNoAlternateIsEmptyOrDuplicated() { diff --git a/ios/Tests/AppReviewBoundaryTests.swift b/ios/Tests/AppReviewBoundaryTests.swift new file mode 100644 index 0000000..3cf41b4 --- /dev/null +++ b/ios/Tests/AppReviewBoundaryTests.swift @@ -0,0 +1,710 @@ +import Foundation +import XCTest + +/// Keeps the App review 2.5.2 boundary visible in the test suite. +final class AppReviewBoundaryTests: XCTestCase { + + private var iosRoot: URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + } + + private var repositoryRoot: URL { + iosRoot.deletingLastPathComponent() + } + + func testFirstPartyPrivacyManifestDeclaresEveryUserDefaultsReason() throws { + let manifestURL = iosRoot.appendingPathComponent("Shared/PrivacyInfo.xcprivacy") + let data = try Data(contentsOf: manifestURL) + let manifest = try XCTUnwrap( + PropertyListSerialization.propertyList(from: data, format: nil) + as? [String: Any] + ) + let accessedAPIs = try XCTUnwrap( + manifest["NSPrivacyAccessedAPITypes"] as? [[String: Any]] + ) + let defaults = try XCTUnwrap( + accessedAPIs.first { + $0["NSPrivacyAccessedAPIType"] as? String + == "NSPrivacyAccessedAPICategoryUserDefaults" + } + ) + let reasons = try XCTUnwrap( + defaults["NSPrivacyAccessedAPITypeReasons"] as? [String] + ) + + XCTAssertEqual(Set(reasons), ["CA92.1", "1C8F.1"]) + + let generatedProject = try source(at: "KeyJawn.xcodeproj/project.pbxproj") + XCTAssertGreaterThanOrEqual( + generatedProject.occurrences(of: "PrivacyInfo.xcprivacy in Resources"), + 2, + "the app and keyboard extension must each copy the shared manifest" + ) + } + + func testBothShippingTargetsExplicitlySupportIPhoneAndIPad() throws { + let project = try source(at: "project.yml") + XCTAssertEqual( + project.occurrences(of: "TARGETED_DEVICE_FAMILY: \"1,2\""), + 2, + "the app and keyboard extension must declare their device families" + ) + } + + func testCurrentReleaseSourcesRecordWaitingForReviewWithoutClaimingApproval() throws { + let currentStateSources = [ + "CHANGELOG.md", + "CLAUDE.md", + "README.md", + "SOCIAL.md", + "docs/app-store-v1.0-metadata.md", + "docs/claude/worker-readme.md", + "docs/ios-app-review.md", + "docs/release-style-guide.md", + "worker/worker/content.py", + ] + + for path in currentStateSources { + let source = try repositorySource(at: path) + .lowercased() + .split(whereSeparator: \Character.isWhitespace) + .joined(separator: " ") + XCTAssertTrue(source.contains("build 9"), "\(path) must identify the build") + XCTAssertTrue( + source.contains("fresh signed build 9 archive"), + "\(path) must record the current archive" + ) + XCTAssertTrue(source.contains("uploaded"), "\(path) must record the upload") + XCTAssertTrue(source.contains("valid"), "\(path) must record Apple's processing result") + XCTAssertTrue( + source.contains("selected for version 1.0"), + "\(path) must record the selected version" + ) + XCTAssertTrue(source.contains("manual release"), "\(path) must record the release mode") + XCTAssertTrue(source.contains("waiting for review"), "\(path) must record the review state") + XCTAssertTrue(source.contains("not approved"), "\(path) must not claim approval") + XCTAssertTrue( + source.contains("not publicly released"), + "\(path) must not claim public release" + ) + XCTAssertFalse( + source.contains("build 9 is not selected"), + "\(path) has the pre-selection build 9 state" + ) + XCTAssertFalse( + source.contains("build 9 is not uploaded") + || source.contains("build 9 remains not uploaded"), + "\(path) has the pre-upload build 9 state" + ) + XCTAssertFalse( + source.contains("needs a fresh archive"), + "\(path) has the superseded pre-archive state" + ) + XCTAssertFalse(source.contains("did not archive"), "\(path) has stale archive state") + XCTAssertFalse(source.contains("not archived"), "\(path) has stale archive state") + XCTAssertFalse(source.contains("was not archived"), "\(path) has stale archive state") + } + } + + func testWebsiteDeploymentActionsArePinnedToReviewedCommits() throws { + let workflow = try repositorySource(at: ".github/workflows/deploy-site.yml") + let actionLines = workflow.split(separator: "\n").filter { $0.contains("uses:") } + XCTAssertEqual(actionLines.count, 3) + + for line in actionLines { + XCTAssertNotNil( + line.range( + of: #"uses:\s+[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+@[0-9a-f]{40}(?:\s+#.*)?$"#, + options: .regularExpression + ), + "deployment action is not pinned: \(line)" + ) + } + } + + func testWebsiteNavigationHasOneChangelogDestination() throws { + let navigation = try repositorySource(at: "website/src/components/Nav.astro") + XCTAssertEqual(navigation.occurrences(of: "href: '/changelog'"), 1) + XCTAssertEqual(navigation.occurrences(of: "href=\"/changelog\""), 0) + } + + func testScreenshotFlowsWaitForTheStateTheyCapture() throws { + let onboarding = try source(at: "UITests/LaunchFlowTests.swift") + XCTAssertTrue( + onboarding.contains("app.staticTexts[\"Turn on the keyboard\"].waitForExistence") + ) + + let keyboard = try source(at: "UITests/KeyboardIMETests.swift") + let tlsControl = try XCTUnwrap(keyboard.range(of: "switches[\"TLS tunnel\"]")) + let screenshot = try XCTUnwrap( + keyboard.range(of: "attachScreenshot(named: screenshotName)") + ) + XCTAssertLessThan(tlsControl.lowerBound, screenshot.lowerBound) + } + + func testReviewDocumentsDiscloseVoiceAndShortcutAccessBoundaries() throws { + let evidence = try String( + contentsOf: repositoryRoot.appendingPathComponent("docs/ios-app-review.md"), + encoding: .utf8 + ) + let metadata = try String( + contentsOf: repositoryRoot.appendingPathComponent( + "docs/app-store-v1.0-metadata.md" + ), + encoding: .utf8 + ) + + for document in [evidence, metadata] { + XCTAssertTrue(document.contains("Apple's Speech framework")) + XCTAssertTrue(document.contains("does not use the microphone")) + XCTAssertTrue(document.contains("User-created shortcuts")) + XCTAssertTrue(document.contains("require Full Access")) + } + XCTAssertTrue(metadata.contains("built-in text shortcuts work without Allow Full Access")) + } + + func testReviewerSetsUploadPathAfterSwitchingToKeyAuthentication() throws { + let metadata = try repositorySource(at: "docs/app-store-v1.0-metadata.md") + let switchToKey = try XCTUnwrap( + metadata.range(of: "select SSH key authentication") + ) + let setUploadPath = try XCTUnwrap( + metadata.range(of: "set Upload path to the writable remote directory above") + ) + let testUpload = try XCTUnwrap( + metadata.range(of: "Copy a small image, tap the SCP key") + ) + + XCTAssertLessThan(switchToKey.lowerBound, setUploadPath.lowerBound) + XCTAssertLessThan(setUploadPath.lowerBound, testUpload.lowerBound) + } + + func testReleaseDocumentsProtectPublicationPrivacyAndComplianceGates() throws { + let releaseDocuments = [ + "CLAUDE.md", + "docs/handoffs/2026-08-27-app-review-remediation-pause.md", + "docs/ios-app-review.md", + "tasks/todo.md", + ] + + for path in releaseDocuments { + let document = try repositorySource(at: path) + XCTAssertTrue( + document.contains("website/**") && document.contains("publication"), + "\(path) must state that a main-branch website change publishes the site" + ) + } + + let evidence = try repositorySource(at: "docs/ios-app-review.md") + XCTAssertTrue(evidence.contains("ITSAppUsesNonExemptEncryption")) + XCTAssertTrue(evidence.contains("export-compliance determination")) + XCTAssertTrue(evidence.contains("App Privacy")) + XCTAssertTrue(evidence.contains("Publish")) + } + + func testCIExecutesTheSystemKeyboardSuiteOnIPad() throws { + let workflow = try repositorySource(at: ".github/workflows/ios-test.yml") + XCTAssertTrue(workflow.contains("iPad")) + XCTAssertTrue(workflow.contains("-only-testing:KeyJawnUITests/KeyboardIMETests")) + } + + func testCIRunsTheSystemKeyboardSuiteOnlyOnThePinnedIPad() throws { + let workflow = try repositorySource(at: ".github/workflows/ios-test.yml") + let iPhoneStepStart = try XCTUnwrap(workflow.range(of: "- name: Test KeyJawn")) + let iPadPickerStart = try XCTUnwrap(workflow.range(of: "- name: Pick iPad simulator")) + let iPhoneStep = workflow[iPhoneStepStart.lowerBound..?")) + XCTAssertTrue(uploadSource.contains("uploadTask?.cancel()")) + XCTAssertTrue( + uploadSource.contains("guard !Task.isCancelled"), + "a canceled upload must not insert its remote path" + ) + XCTAssertTrue( + uploadSource.contains(".hostKeyVerificationRequired"), + "an unpinned SSH-key host needs its own repair instruction" + ) + XCTAssertTrue( + uploadSource.contains("panel.isDismissEnabled = false"), + "the visible Cancel button must stop before remote writing starts" + ) + } + } + + func testShippingSourcesDoNotExposePrivateHostAliases() throws { + let privateHostAliases = ["houseofjawn", "officejawn", "landofjawn"] + + for file in try shippingSwiftFiles() { + let text = try String(contentsOf: file, encoding: .utf8).lowercased() + for alias in privateHostAliases { + XCTAssertFalse( + text.contains(alias), + "\(relativePath(of: file)) exposes private host alias \(alias)" + ) + } + } + } + + private func source(at relativePath: String) throws -> String { + try String( + contentsOf: iosRoot.appendingPathComponent(relativePath), + encoding: .utf8 + ) + } + + private func repositorySource(at relativePath: String) throws -> String { + try String( + contentsOf: repositoryRoot.appendingPathComponent(relativePath), + encoding: .utf8 + ) + } + + private func appInfoDictionary() throws -> [String: Any] { + try infoDictionary(at: "KeyJawn/Info.plist") + } + + private func keyboardInfoDictionary() throws -> [String: Any] { + try infoDictionary(at: "KeyJawnKeyboard/Info.plist") + } + + private func infoDictionary(at relativePath: String) throws -> [String: Any] { + let data = try Data(contentsOf: iosRoot.appendingPathComponent(relativePath)) + return try XCTUnwrap( + PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] + ) + } + + private func shippingSwiftFiles() throws -> [URL] { + let sourceDirectories = ["KeyJawn", "KeyJawnKeyboard", "KeyJawnKit/Sources", "Shared"] + .map(iosRoot.appendingPathComponent) + let files = sourceDirectories.flatMap { directory in + guard + let enumerator = FileManager.default.enumerator( + at: directory, + includingPropertiesForKeys: [.isRegularFileKey] + ) + else { + XCTFail("cannot enumerate \(directory.path)") + return [URL]() + } + return enumerator.compactMap { $0 as? URL }.filter { $0.pathExtension == "swift" } + } + let paths = Set(files.map(relativePath)) + let requiredPaths = Set([ + "KeyJawn/App/ContentView.swift", + "KeyJawnKeyboard/KeyboardViewController.swift", + "KeyJawnKit/Sources/KeyJawnKit/Models/SSHIdentityKeyStore.swift", + "Shared/CitadelSCPUploader.swift", + ]) + XCTAssertTrue( + requiredPaths.isSubset(of: paths), + "shipping source scan is missing a required source root" + ) + return files + } + + private func relativePath(of file: URL) -> String { + let canonicalRoot = iosRoot.resolvingSymlinksInPath().standardizedFileURL.path + let canonicalFile = file.resolvingSymlinksInPath().standardizedFileURL.path + return canonicalFile.replacingOccurrences(of: canonicalRoot + "/", with: "") + } +} + +extension String { + fileprivate func occurrences(of value: String) -> Int { + components(separatedBy: value).count - 1 + } +} diff --git a/ios/Tests/ExtensionHonestyTests.swift b/ios/Tests/ExtensionHonestyTests.swift index 3da8294..c0a9955 100644 --- a/ios/Tests/ExtensionHonestyTests.swift +++ b/ios/Tests/ExtensionHonestyTests.swift @@ -1,4 +1,5 @@ import XCTest + @testable import KeyJawnKit /// Structural checks that the keyboard extension does not grow a dead mic. @@ -8,29 +9,124 @@ final class ExtensionHonestyTests: XCTestCase { let root = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() .deletingLastPathComponent() - let extensionDir = root.appendingPathComponent("KeyJawnKeyboard") - let files = try FileManager.default.contentsOfDirectory( - at: extensionDir, - includingPropertiesForKeys: nil - ).filter { $0.pathExtension == "swift" } + let projectYAML = try String( + contentsOf: root.appendingPathComponent("project.yml"), + encoding: .utf8 + ) + let keyboardTarget = try XCTUnwrap( + projectYAML.components(separatedBy: " KeyJawnKeyboard:\n").last? + .components(separatedBy: "\n KeyJawnKitTests:\n").first + ) + let declaredSources = try XCTUnwrap( + keyboardTarget.components(separatedBy: " sources:\n").last? + .components(separatedBy: " dependencies:\n").first + ).trimmingCharacters(in: .whitespacesAndNewlines) + XCTAssertEqual( + declaredSources, + "- path: KeyJawnKeyboard\n - path: Shared", + "update the extension safety scan when target sources change" + ) + let declaredDependencies = try XCTUnwrap( + keyboardTarget.components(separatedBy: " dependencies:\n").last? + .components(separatedBy: " settings:\n").first + ).trimmingCharacters(in: .whitespacesAndNewlines) + XCTAssertEqual( + declaredDependencies, + """ + - package: KeyJawnKit + product: KeyJawnKit + - package: Citadel + - package: SwiftNIO + product: NIOCore + - package: SwiftNIOSSH + product: NIOSSH + - package: SwiftNIOTransportServices + product: NIOTransportServices + """, + "update the extension safety scan when target dependencies change" + ) + + let sourceDirectories = [ + "KeyJawnKeyboard", + "Shared", + "KeyJawnKit/Sources/KeyJawnKit", + ] + var files: [URL] = [] + for directoryName in sourceDirectories { + let directory = root.appendingPathComponent(directoryName) + guard + let enumerator = FileManager.default.enumerator( + at: directory, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + else { + XCTFail("cannot enumerate \(directoryName) sources") + continue + } + for case let file as URL in enumerator where file.pathExtension == "swift" { + files.append(file) + } + } + + let relativePaths = Set( + files.map { file in + String(file.path.dropFirst(root.path.count + 1)) + }) XCTAssertFalse(files.isEmpty, "missing keyboard extension sources") + XCTAssertTrue( + relativePaths.contains("KeyJawnKeyboard/KeyboardViewController.swift"), + "missing keyboard controller from extension source scan" + ) + XCTAssertTrue( + relativePaths.contains("Shared/CitadelSCPUploader.swift"), + "missing shared uploader from extension source scan" + ) + XCTAssertTrue( + relativePaths.contains("KeyJawnKit/Sources/KeyJawnKit/Views/ExtraRowView.swift"), + "missing linked keyboard views from extension source scan" + ) for file in files { let text = try String(contentsOf: file, encoding: .utf8) XCTAssertFalse(text.contains("import Speech"), "\(file.lastPathComponent) imports Speech") XCTAssertFalse(text.contains("SFSpeechRecognizer"), "\(file.lastPathComponent) uses SFSpeechRecognizer") + XCTAssertFalse(text.contains("import AVFoundation"), "\(file.lastPathComponent) imports AVFoundation") XCTAssertFalse(text.contains("AVAudioEngine"), "\(file.lastPathComponent) opens the audio engine") + XCTAssertFalse(text.contains("AVAudioSession"), "\(file.lastPathComponent) configures an audio session") + XCTAssertFalse(text.contains("AVAudioRecorder"), "\(file.lastPathComponent) records audio") + XCTAssertFalse(text.contains("AVCaptureDevice"), "\(file.lastPathComponent) accesses a capture device") XCTAssertFalse(text.contains("requestRecordPermission"), "\(file.lastPathComponent) requests the mic") } } - func testExtensionDefaultRowHasNoMic() { - XCTAssertFalse( - ExtraRowKey.defaults.contains { $0.slot == .mic }, - "the IME extra row must not show a dead mic" - ) + func testExtensionPresetsHaveNoMic() throws { + for preset in ExtraRowPreset.allCases { + XCTAssertFalse( + preset.keys.contains { $0.slot == .mic }, + "the IME \(preset.displayName) row must not show a dead mic" + ) + } XCTAssertTrue( ExtraRowKey.terminalKeys.contains { $0.slot == .mic }, "the SSH extra row is where the mic lives" ) + + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let controller = root.appendingPathComponent( + "KeyJawnKeyboard/KeyboardViewController.swift" + ) + let source = try String(contentsOf: controller, encoding: .utf8) + let setKeysCallCount = source.components(separatedBy: "extraRow.setKeys(").count - 1 + XCTAssertEqual( + setKeysCallCount, + 1, + "the extension must have one audited extra-row key source" + ) + XCTAssertTrue( + source.contains("extraRow.setKeys(KeyboardPrefs.shared.extraRowPreset.keys)"), + "the extension must use mic-free preset keys" + ) } } diff --git a/ios/Tests/HostConfigTests.swift b/ios/Tests/HostConfigTests.swift index 738d33a..ce0e68c 100644 --- a/ios/Tests/HostConfigTests.swift +++ b/ios/Tests/HostConfigTests.swift @@ -1,6 +1,7 @@ import XCTest -@testable import KeyJawnKit + @testable import KeyJawn +@testable import KeyJawnKit /// `HostConfig` is the wire format between the app and the keyboard extension: the app /// encodes it into the shared container and the extension decodes it there. A field @@ -13,7 +14,7 @@ final class HostConfigTests: XCTestCase { "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKqG9GzSGc2k1eXNzrkfoE2ag8NtdHfV7fMuWq5GxY8a" private let sample = HostConfig( - label: "houseofjawn", + label: "remote", hostname: "example.internal", port: 2222, username: "jawn", @@ -48,6 +49,34 @@ final class HostConfigTests: XCTestCase { XCTAssertEqual(host.authMethod, .key) XCTAssertNil(host.hostPublicKey) XCTAssertEqual(host.uploadPath, "/tmp") + XCTAssertFalse(host.usesTLSTunnel) + } + + func testLegacyHostWithoutTLSTunnelDecodesAsPlainSSH() throws { + let data = try JSONEncoder().encode(sample) + var object = try XCTUnwrap( + JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + object.removeValue(forKey: "usesTLSTunnel") + let legacyData = try JSONSerialization.data(withJSONObject: object) + + let decoded = try JSONDecoder().decode(HostConfig.self, from: legacyData) + + XCTAssertFalse(decoded.usesTLSTunnel) + } + + func testTLSTunnelRoundTripsThroughJSON() throws { + let host = HostConfig( + label: "tls", + hostname: "review.example", + port: 10_000, + username: "reviewer", + usesTLSTunnel: true + ) + + let data = try JSONEncoder().encode(host) + + XCTAssertEqual(try JSONDecoder().decode(HostConfig.self, from: data), host) } func testValidation() { @@ -76,11 +105,33 @@ final class HostConfigTests: XCTestCase { func testScanCommandMatchesTheHostItIsBuiltFrom() { for port: UInt16 in [22, 22222, 2022, 1] { let host = HostConfig(label: "a", hostname: "h.internal", port: port, username: "u") - XCTAssertEqual(host.hostKeyScanCommand, - HostConfig.hostKeyScanCommand(hostname: "h.internal", port: port)) + XCTAssertEqual( + host.hostKeyScanCommand, + HostConfig.hostKeyScanCommand(hostname: "h.internal", port: port)) } } + func testTLSTunnelDoesNotOfferAPlainHostKeyScanCommand() { + XCTAssertNil( + HostConfig.hostKeyScanCommand( + hostname: "review.example", + port: 10_000, + usesTLSTunnel: true + ) + ) + } + + func testPlainSSHOffersAHostKeyScanCommand() { + XCTAssertEqual( + HostConfig.hostKeyScanCommand( + hostname: "example.internal", + port: 2222, + usesTLSTunnel: false + ), + "ssh-keyscan -p 2222 -t ed25519 example.internal" + ) + } + func testAuthMethodRawValuesAreStable() { // Persisted verbatim in the shared container; renaming a case would orphan // every host an existing install has saved. @@ -88,6 +139,51 @@ final class HostConfigTests: XCTestCase { XCTAssertEqual(HostConfig.AuthMethod.password.rawValue, "password") } + func testCopiedImageUploadUsesOnlyReadyKeyAuthenticatedHosts() { + let passwordHost = HostConfig( + label: "password", + hostname: "password.internal", + username: "reviewer", + authMethod: .password + ) + let unpinnedKeyHost = HostConfig( + label: "unpinned", + hostname: "unpinned.internal", + username: "reviewer", + authMethod: .key + ) + + XCTAssertEqual( + HostConfig.copiedImageUploadHosts( + from: [passwordHost, unpinnedKeyHost, sample] + ), + [sample] + ) + } + + func testCopiedImageUploadRejectsPasswordBeforeConnection() async { + let passwordHost = HostConfig( + label: "password", + hostname: "password.internal", + username: "reviewer", + authMethod: .password, + hostPublicKey: knownHostKey + ) + + do { + _ = try await CitadelSCPUploader.upload( + imageData: Data(), + to: passwordHost, + privateKeyData: Data([0]) + ) + XCTFail("Password authentication must be rejected before an upload starts") + } catch CitadelSCPUploader.UploadError.keyAuthenticationRequired { + // Expected: copied-image upload has no password credential to use. + } catch { + XCTFail("Expected keyAuthenticationRequired, got \(error)") + } + } + // MARK: - Host key trust func testMissingOrBlankHostKeyIsNotPinned() { diff --git a/ios/Tests/KeyboardInsertPathTests.swift b/ios/Tests/KeyboardInsertPathTests.swift index 8370964..b08597b 100644 --- a/ios/Tests/KeyboardInsertPathTests.swift +++ b/ios/Tests/KeyboardInsertPathTests.swift @@ -51,10 +51,13 @@ final class KeyboardInsertPathTests: XCTestCase { ) } - func testArmedCtrlSurvivesAMultiCharacterAlternate() { + func testArmedCtrlSurvivesAnUnmappedAlternate() { XCTAssertTrue( KeyboardDocumentInsert.consumesCtrl(for: .character("c"), ctrlActive: true) ) + XCTAssertFalse( + KeyboardDocumentInsert.consumesCtrl(for: .character("é"), ctrlActive: true) + ) XCTAssertFalse( KeyboardDocumentInsert.consumesCtrl(for: .character(".."), ctrlActive: true) ) @@ -122,6 +125,89 @@ final class KeyboardInsertPathTests: XCTestCase { window.isHidden = true } + func testQwertyUsesDenseRowsWithoutShrinkingPortraitTouchTargets() { + let pad = QwertyKeyboardView(frame: CGRect(x: 0, y: 0, width: 660, height: 300)) + assertDenseLayout(pad) + tapQwerty(pad, title: "shift") + assertDenseLayout(pad) + tapQwerty(pad, title: "123") + assertDenseLayout(pad) + tapQwerty(pad, title: "#+=") + assertDenseLayout(pad) + + let phone = QwertyKeyboardView(frame: CGRect(x: 0, y: 0, width: 390, height: 220)) + assertDenseLayout(phone) + } + + func testLetterKeysMatchTheDeviceFlickCapability() throws { + let qwerty = QwertyKeyboardView(frame: CGRect(x: 0, y: 0, width: 660, height: 300)) + let recorder = QwertyRecorder() + qwerty.delegate = recorder + qwerty.layoutIfNeeded() + + let qKey = try XCTUnwrap( + qwerty.subviews.compactMap { $0 as? UIButton }.first { $0.currentTitle == "q" } + ) + let downwardFlick = qKey.gestureRecognizers? + .compactMap { $0 as? UISwipeGestureRecognizer } + .first { $0.direction == .down } + let visibleLabels = qKey.subviews.compactMap { ($0 as? UILabel)?.text } + + if UIDevice.current.userInterfaceIdiom == .pad { + XCTAssertNotNil(downwardFlick) + XCTAssertEqual(downwardFlick?.cancelsTouchesInView, true) + XCTAssertTrue(visibleLabels.contains("1")) + XCTAssertEqual(qKey.accessibilityHint, "Swipe down for 1") + let action = try XCTUnwrap(qKey.accessibilityCustomActions?.first) + XCTAssertEqual(action.name, "Insert 1") + XCTAssertTrue(action.actionHandler?(action) == true) + XCTAssertEqual(recorder.inserted, ["1"]) + + for theme in KeyboardTheme.allCases { + qwerty.applyTheme(theme) + let rebuiltQ = try XCTUnwrap( + qwerty.subviews.compactMap { $0 as? UIButton }.first { $0.currentTitle == "q" } + ) + let flicks = rebuiltQ.gestureRecognizers? + .compactMap { $0 as? UISwipeGestureRecognizer } + .filter { $0.direction == .down } + let labels = rebuiltQ.subviews.compactMap { ($0 as? UILabel)?.text } + XCTAssertEqual(flicks?.count, 1) + XCTAssertEqual(labels.filter { $0 == "1" }.count, 1) + } + } else { + XCTAssertNil(downwardFlick) + XCTAssertFalse(visibleLabels.contains("1")) + XCTAssertNil(qKey.accessibilityHint) + } + } + + func testCtrlStateHasSpokenAndNonColorIndicators() throws { + let extra = ExtraRowView(frame: CGRect(x: 0, y: 0, width: 640, height: 62)) + extra.layoutIfNeeded() + let button = try XCTUnwrap( + extra.subviews + .flatMap(\.subviews) + .compactMap { $0 as? UIButton } + .first { $0.accessibilityIdentifier == "extra.ctrlC" } + ) + + XCTAssertEqual(button.accessibilityValue, "Off") + XCTAssertEqual(button.currentTitle, "^C") + + extra.ctrl.toggle() + XCTAssertEqual(button.accessibilityValue, "Armed for next key") + XCTAssertEqual(button.currentTitle, "^C·") + + extra.ctrl.toggle() + XCTAssertEqual(button.accessibilityValue, "Locked") + XCTAssertEqual(button.currentTitle, "^C∞") + + extra.ctrl.toggle() + XCTAssertEqual(button.accessibilityValue, "Off") + XCTAssertEqual(button.currentTitle, "^C") + } + func testSlashPanelSelectsCompactTrigger() { let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 400)) let panel = SlashCommandPanel(theme: .dark) @@ -163,8 +249,10 @@ final class KeyboardInsertPathTests: XCTestCase { let button = extra.subviews .flatMap(\.subviews) .compactMap { $0 as? UIButton } - .first { $0.accessibilityLabel?.contains(accessibility) == true - || $0.currentTitle == accessibility } + .first { + $0.accessibilityLabel?.contains(accessibility) == true + || $0.currentTitle == accessibility + } XCTAssertNotNil(button, "missing extra-row control \(accessibility)") button?.sendActions(for: .touchUpInside) } @@ -180,11 +268,38 @@ final class KeyboardInsertPathTests: XCTestCase { private func sendBackspace(_ qwerty: QwertyKeyboardView) { let button = qwerty.subviews .compactMap { $0 as? UIButton } - .first { $0.accessibilityLabel?.localizedCaseInsensitiveContains("delete") == true - || $0.accessibilityLabel?.localizedCaseInsensitiveContains("backspace") == true } + .first { + $0.accessibilityLabel?.localizedCaseInsensitiveContains("delete") == true + || $0.accessibilityLabel?.localizedCaseInsensitiveContains("backspace") == true + } XCTAssertNotNil(button, "missing backspace") button?.sendActions(for: .touchUpInside) } + + private func assertDenseLayout( + _ qwerty: QwertyKeyboardView, + file: StaticString = #filePath, + line: UInt = #line + ) { + qwerty.layoutIfNeeded() + let frames = qwerty.subviews.compactMap { ($0 as? UIButton)?.frame } + let rowMinYs = Array(Set(frames.map(\.minY))).sorted() + let rowMaxYs = rowMinYs.map { minY in + frames.filter { $0.minY == minY }.map(\.maxY).max() ?? minY + } + let verticalGaps = zip(rowMaxYs, rowMinYs.dropFirst()).map { currentMaxY, nextMinY in + nextMinY - currentMaxY + } + + XCTAssertEqual(rowMinYs.count, 4, file: file, line: line) + for gap in verticalGaps { + XCTAssertEqual(gap, 7, accuracy: 0.01, file: file, line: line) + } + XCTAssertGreaterThanOrEqual(frames.map(\.height).min() ?? 0, 44, file: file, line: line) + for frame in frames { + XCTAssertTrue(qwerty.bounds.contains(frame), "key outside QWERTY bounds", file: file, line: line) + } + } } @MainActor diff --git a/ios/Tests/KeyboardMetricsTests.swift b/ios/Tests/KeyboardMetricsTests.swift index cb2ada1..aa9a1e7 100644 --- a/ios/Tests/KeyboardMetricsTests.swift +++ b/ios/Tests/KeyboardMetricsTests.swift @@ -4,14 +4,17 @@ import XCTest /// The keyboard used to be 322pt on every idiom. iPad must use the pad metrics /// so keys are not a cramped phone strip on a 11/13-inch canvas. +@MainActor final class KeyboardMetricsTests: XCTestCase { func testPadIsTallerThanPhonePortrait() { - let phone = UITraitCollection(traitsFrom: [ - UITraitCollection(userInterfaceIdiom: .phone), - UITraitCollection(verticalSizeClass: .regular), - ]) - let pad = UITraitCollection(userInterfaceIdiom: .pad) + let phone = UITraitCollection { traits in + traits.userInterfaceIdiom = .phone + traits.verticalSizeClass = .regular + } + let pad = UITraitCollection { traits in + traits.userInterfaceIdiom = .pad + } XCTAssertEqual(KeyboardMetrics.current(for: phone), .phonePortrait) XCTAssertEqual(KeyboardMetrics.current(for: pad), .pad) XCTAssertEqual(KeyboardMetrics.phonePortrait.total, 322) @@ -20,12 +23,34 @@ final class KeyboardMetricsTests: XCTestCase { } func testPhoneLandscapeIsShorterThanPhonePortrait() { - let landscape = UITraitCollection(traitsFrom: [ - UITraitCollection(userInterfaceIdiom: .phone), - UITraitCollection(verticalSizeClass: .compact), - ]) + let landscape = UITraitCollection { traits in + traits.userInterfaceIdiom = .phone + traits.verticalSizeClass = .compact + } XCTAssertEqual(KeyboardMetrics.current(for: landscape), .phoneLandscape) XCTAssertEqual(KeyboardMetrics.phoneLandscape.total, 228) XCTAssertLessThan(KeyboardMetrics.phoneLandscape.total, KeyboardMetrics.phonePortrait.total) } + + func testOnlyPadsWideEnoughForTenAccessibleKeysReserveTheAssistantArea() { + let pad = UITraitCollection { traits in + traits.userInterfaceIdiom = .pad + } + let phone = UITraitCollection { traits in + traits.userInterfaceIdiom = .phone + } + + XCTAssertEqual(KeyboardAssistantLayout.leadingInset(for: 599, traits: pad), 0) + XCTAssertEqual(KeyboardAssistantLayout.leadingInset(for: 600, traits: pad), 0) + XCTAssertEqual(KeyboardAssistantLayout.leadingInset(for: 639, traits: pad), 0) + XCTAssertEqual( + KeyboardAssistantLayout.leadingInset(for: 640, traits: pad), + KeyboardAssistantLayout.leadingInset + ) + XCTAssertEqual( + KeyboardAssistantLayout.leadingInset(for: 820, traits: pad), + KeyboardAssistantLayout.leadingInset + ) + XCTAssertEqual(KeyboardAssistantLayout.leadingInset(for: 820, traits: phone), 0) + } } diff --git a/ios/Tests/KeyboardThemeContrastTests.swift b/ios/Tests/KeyboardThemeContrastTests.swift index bb2b21f..432ad3c 100644 --- a/ios/Tests/KeyboardThemeContrastTests.swift +++ b/ios/Tests/KeyboardThemeContrastTests.swift @@ -1,5 +1,5 @@ -import XCTest import UIKit +import XCTest @testable import KeyJawnKit /// Legibility as a test rather than as a thing someone notices on a device. @@ -23,6 +23,13 @@ final class KeyboardThemeContrastTests: XCTestCase { } } + func testFlickLabelsAreLegibleOnKeyFaces() { + for theme in KeyboardTheme.allCases { + let rendered = composite(theme.flickKeyText, over: theme.keyBg) + assertContrast(rendered, on: theme.keyBg, theme: theme, label: "flickKeyText on keyBg") + } + } + func testKeyLabelsAreLegibleOnModifierKeys() { for theme in KeyboardTheme.allCases { assertContrast(theme.keyText, on: theme.specKeyBg, theme: theme, label: "keyText on specKeyBg") @@ -112,6 +119,25 @@ final class KeyboardThemeContrastTests: XCTestCase { return (lighter + 0.05) / (darker + 0.05) } + private func composite(_ foreground: UIColor, over background: UIColor) -> UIColor { + var fr: CGFloat = 0 + var fg: CGFloat = 0 + var fb: CGFloat = 0 + var fa: CGFloat = 0 + var br: CGFloat = 0 + var bg: CGFloat = 0 + var bb: CGFloat = 0 + var ba: CGFloat = 0 + XCTAssertTrue(foreground.getRed(&fr, green: &fg, blue: &fb, alpha: &fa)) + XCTAssertTrue(background.getRed(&br, green: &bg, blue: &bb, alpha: &ba)) + return UIColor( + red: fr * fa + br * (1 - fa), + green: fg * fa + bg * (1 - fa), + blue: fb * fa + bb * (1 - fa), + alpha: 1 + ) + } + private func relativeLuminance(_ color: UIColor) -> Double { let rgb = components(color) func linear(_ channel: Double) -> Double { diff --git a/ios/Tests/OnboardingTests.swift b/ios/Tests/OnboardingTests.swift index d218049..75ad610 100644 --- a/ios/Tests/OnboardingTests.swift +++ b/ios/Tests/OnboardingTests.swift @@ -1,4 +1,6 @@ +import UIKit import XCTest +@testable import KeyJawn @testable import KeyJawnKit final class OnboardingTests: XCTestCase { @@ -50,6 +52,7 @@ final class OnboardingTests: XCTestCase { func testCopyCoversWhatTheAppIs() { let text = OnboardingCopy.allUserVisibleText.lowercased() XCTAssertTrue(text.contains("keyboard"), "must say it is a keyboard") + XCTAssertFalse(text.contains("phone keyboard"), "iPad copy must not call it phone-only") XCTAssertTrue(text.contains("esc"), "must name a terminal key") XCTAssertTrue(text.contains("plain text"), "must say slash inserts text") } @@ -63,6 +66,14 @@ final class OnboardingTests: XCTestCase { XCTAssertTrue(OnboardingCopy.openSettingsTitle == "Open Settings") } + func testCopyExplainsTheOptionalFullAccessFeatures() { + let text = OnboardingCopy.allUserVisibleText.lowercased() + XCTAssertTrue(text.contains("network access")) + XCTAssertTrue(text.contains("remote image upload")) + XCTAssertTrue(text.contains("user-created shortcuts")) + XCTAssertTrue(text.contains("shared settings and clipboard history")) + } + func testCopyCoversAddingAHostAndCopyingThePublicKey() { let text = OnboardingCopy.allUserVisibleText.lowercased() XCTAssertTrue(text.contains("hosts")) @@ -70,6 +81,13 @@ final class OnboardingTests: XCTestCase { XCTAssertTrue(text.contains("authorized_keys")) } + func testCopyExplainsTheRemoteExecutionAndLocalFileBoundary() { + let text = OnboardingCopy.allUserVisibleText.lowercased() + XCTAssertTrue(text.contains("remote ssh server")) + XCTAssertTrue(text.contains("commands run on that server")) + XCTAssertTrue(text.contains("cannot browse files on your iphone or ipad")) + } + func testSkipAndDoneAreAvailable() { XCTAssertEqual(OnboardingCopy.skipTitle, "Skip") XCTAssertEqual(OnboardingCopy.doneTitle, "Done") @@ -90,4 +108,57 @@ final class OnboardingTests: XCTestCase { XCTAssertEqual(OnboardingCopy.pages[1], OnboardingCopy.enableKeyboard) XCTAssertEqual(OnboardingCopy.pages[2], OnboardingCopy.addAHost) } + + // MARK: - Host app theme + + func testHostAppThemeMatchesTheKeyJawnPalette() { + XCTAssertEqual(rgb(AppTheme.backgroundColor), RGB(red: 10, green: 10, blue: 15)) + XCTAssertEqual(rgb(AppTheme.surfaceColor), RGB(red: 20, green: 20, blue: 28)) + XCTAssertEqual(rgb(AppTheme.textColor), RGB(red: 228, green: 228, blue: 236)) + XCTAssertEqual(rgb(AppTheme.mintColor), RGB(red: 108, green: 242, blue: 168)) + XCTAssertEqual(rgb(AppTheme.warmColor), RGB(red: 242, green: 108, blue: 138)) + } + + func testHostAppTextAndActionsMeetTheContrastFloor() { + XCTAssertGreaterThanOrEqual(contrast(AppTheme.textColor, AppTheme.backgroundColor), 4.5) + XCTAssertGreaterThanOrEqual(contrast(AppTheme.textColor, AppTheme.surfaceColor), 4.5) + XCTAssertGreaterThanOrEqual(contrast(AppTheme.mintColor, AppTheme.backgroundColor), 4.5) + XCTAssertGreaterThanOrEqual(contrast(AppTheme.warmColor, AppTheme.backgroundColor), 4.5) + } + + private struct RGB: Equatable { + let red: Int + let green: Int + let blue: Int + } + + private func rgb(_ color: UIColor) -> RGB { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + XCTAssertTrue(color.getRed(&red, green: &green, blue: &blue, alpha: &alpha)) + return RGB( + red: Int(round(red * 255)), + green: Int(round(green * 255)), + blue: Int(round(blue * 255)) + ) + } + + private func contrast(_ foreground: UIColor, _ background: UIColor) -> Double { + let lighter = max(luminance(foreground), luminance(background)) + let darker = min(luminance(foreground), luminance(background)) + return (lighter + 0.05) / (darker + 0.05) + } + + private func luminance(_ color: UIColor) -> Double { + let value = rgb(color) + func linear(_ channel: Int) -> Double { + let component = Double(channel) / 255 + return component <= 0.03928 + ? component / 12.92 + : pow((component + 0.055) / 1.055, 2.4) + } + return 0.2126 * linear(value.red) + 0.7152 * linear(value.green) + 0.0722 * linear(value.blue) + } } diff --git a/ios/Tests/SSHErrorPresentationTests.swift b/ios/Tests/SSHErrorPresentationTests.swift new file mode 100644 index 0000000..bf2e096 --- /dev/null +++ b/ios/Tests/SSHErrorPresentationTests.swift @@ -0,0 +1,246 @@ +import Citadel +import Foundation +import NIOCore +import NIOEmbedded +import XCTest + +@testable import KeyJawn + +final class SSHErrorPresentationTests: XCTestCase { + func testTransportFailureHasActionableUserMessage() { + XCTAssertEqual( + AppleNetworkSSHTransportError.connectionFailed.localizedDescription, + "Could not connect to the SSH server. Check the host, port, TLS tunnel setting, and network connection." + ) + } + + func testAuthenticationFailureHasActionableUserMessage() { + XCTAssertEqual( + AppleNetworkSSHTransport.userFacingError( + SSHClientError.allAuthenticationOptionsFailed + ) as? AppleNetworkSSHTransportError, + .authenticationFailed + ) + } + + func testEarlyChannelCloseDoesNotExposeLibraryError() { + XCTAssertEqual( + AppleNetworkSSHTransport.userFacingError(ChannelError.eof) + as? AppleNetworkSSHTransportError, + .sshHandshakeFailed + ) + } + + func testUnsupportedServerAuthenticationMethodsAreAuthenticationFailures() { + let errors: [SSHClientError] = [ + .unsupportedPasswordAuthentication, + .unsupportedPrivateKeyAuthentication, + .unsupportedHostBasedAuthentication, + ] + + for error in errors { + XCTAssertEqual( + AppleNetworkSSHTransport.userFacingError(error) + as? AppleNetworkSSHTransportError, + .authenticationFailed + ) + } + } + + func testCitadelAuthenticationTimeoutUsesTimeoutMessage() { + XCTAssertEqual( + AppleNetworkSSHTransport.userFacingError( + ChannelError.connectTimeout(.seconds(10)) + ) as? AppleNetworkSSHTransportError, + .operationTimedOut + ) + } + + func testBootstrapConnectionTimeoutUsesTimeoutMessage() { + XCTAssertEqual( + AppleNetworkSSHTransport.connectionError( + ChannelError.connectTimeout(.seconds(30)) + ), + .operationTimedOut + ) + } + + func testUploaderPreservesCancellationDuringConnection() { + XCTAssertTrue( + CitadelSCPUploader.connectionFailure( + from: CancellationError() + ) is CancellationError + ) + } + + func testUploaderMapsConnectionAuthenticationAndTimeoutFailures() { + let cases: [(Error, CitadelSCPUploader.UploadError)] = [ + ( + AppleNetworkSSHTransportError.connectionFailed, + .connectionFailed + ), + ( + AppleNetworkSSHTransportError.authenticationFailed, + .authenticationFailed + ), + ( + AppleNetworkSSHTransportError.operationTimedOut, + .uploadTimedOut + ), + ] + + for (error, expected) in cases { + XCTAssertEqual( + CitadelSCPUploader.connectionFailure(from: error) + as? CitadelSCPUploader.UploadError, + expected + ) + } + } + + func testUploaderPreservesCancellationDuringUpload() { + XCTAssertTrue( + CitadelSCPUploader.uploadFailure( + from: CancellationError(), + didTimeOut: false + ) is CancellationError + ) + } + + func testSFTPSetupTimeoutUsesUploadTimeoutMessage() { + XCTAssertEqual( + CitadelSCPUploader.uploadFailure( + from: SFTPError.missingResponse, + didTimeOut: false + ) as? CitadelSCPUploader.UploadError, + .uploadTimedOut + ) + } + + func testDeadlineFlagUsesUploadTimeoutMessage() { + XCTAssertEqual( + CitadelSCPUploader.uploadFailure( + from: ChannelError.eof, + didTimeOut: true + ) as? CitadelSCPUploader.UploadError, + .uploadTimedOut + ) + } + + func testOperationDeadlineClosesAStalledChannel() throws { + let loop = EmbeddedEventLoop() + let channel = EmbeddedChannel(loop: loop) + try channel.connect( + to: SocketAddress(ipAddress: "127.0.0.1", port: 22) + ).wait() + let deadline = AppleNetworkOperationDeadline( + channel: channel, + timeout: .seconds(1) + ) + + loop.advanceTime(by: .seconds(1)) + + XCTAssertTrue(deadline.didTimeOut) + XCTAssertFalse(channel.isActive) + XCTAssertNoThrow(try channel.finish(acceptAlreadyClosed: true)) + } + + func testCancelledOperationDeadlineKeepsTheChannelOpen() throws { + let loop = EmbeddedEventLoop() + let channel = EmbeddedChannel(loop: loop) + try channel.connect( + to: SocketAddress(ipAddress: "127.0.0.1", port: 22) + ).wait() + let deadline = AppleNetworkOperationDeadline( + channel: channel, + timeout: .seconds(1) + ) + + deadline.cancel() + loop.advanceTime(by: .seconds(1)) + + XCTAssertFalse(deadline.didTimeOut) + XCTAssertTrue(channel.isActive) + XCTAssertNoThrow(try channel.finish()) + } + + func testCompletingAnOperationWinsBeforeItsDeadline() throws { + let loop = EmbeddedEventLoop() + let channel = EmbeddedChannel(loop: loop) + try channel.connect( + to: SocketAddress(ipAddress: "127.0.0.1", port: 22) + ).wait() + let deadline = AppleNetworkOperationDeadline( + channel: channel, + timeout: .seconds(1) + ) + + XCTAssertTrue(deadline.complete()) + loop.advanceTime(by: .seconds(1)) + + XCTAssertFalse(deadline.didTimeOut) + XCTAssertTrue(channel.isActive) + XCTAssertNoThrow(try channel.finish()) + } + + func testCompletingAnOperationLosesAfterItsDeadline() throws { + let loop = EmbeddedEventLoop() + let channel = EmbeddedChannel(loop: loop) + try channel.connect( + to: SocketAddress(ipAddress: "127.0.0.1", port: 22) + ).wait() + let deadline = AppleNetworkOperationDeadline( + channel: channel, + timeout: .seconds(1) + ) + + loop.advanceTime(by: .seconds(1)) + + XCTAssertFalse(deadline.complete()) + XCTAssertTrue(deadline.didTimeOut) + XCTAssertNoThrow(try channel.finish(acceptAlreadyClosed: true)) + } + + func testChannelCloserClosesAnActiveChannel() throws { + let loop = EmbeddedEventLoop() + let channel = EmbeddedChannel(loop: loop) + try channel.connect( + to: SocketAddress(ipAddress: "127.0.0.1", port: 22) + ).wait() + + AppleNetworkChannelCloser(channel: channel).close() + + XCTAssertFalse(channel.isActive) + XCTAssertNoThrow(try channel.finish(acceptAlreadyClosed: true)) + } + + func testChannelBoxClosesAChannelStoredAfterCancellation() throws { + let loop = EmbeddedEventLoop() + let channel = EmbeddedChannel(loop: loop) + try channel.connect( + to: SocketAddress(ipAddress: "127.0.0.1", port: 22) + ).wait() + let box = AppleNetworkChannelBox() + + box.close() + box.store(channel) + + XCTAssertFalse(channel.isActive) + XCTAssertNoThrow(try channel.finish(acceptAlreadyClosed: true)) + } + + func testChannelBoxClosesAnAlreadyStoredChannel() throws { + let loop = EmbeddedEventLoop() + let channel = EmbeddedChannel(loop: loop) + try channel.connect( + to: SocketAddress(ipAddress: "127.0.0.1", port: 22) + ).wait() + let box = AppleNetworkChannelBox() + + box.store(channel) + box.close() + + XCTAssertFalse(channel.isActive) + XCTAssertNoThrow(try channel.finish(acceptAlreadyClosed: true)) + } +} diff --git a/ios/Tests/SSHLiveIntegrationTests.swift b/ios/Tests/SSHLiveIntegrationTests.swift new file mode 100644 index 0000000..d658411 --- /dev/null +++ b/ios/Tests/SSHLiveIntegrationTests.swift @@ -0,0 +1,262 @@ +import CryptoKit +import Foundation +import KeyJawnKit +import XCTest + +@testable import KeyJawn + +/// Opt-in checks against a real SSH server. +/// +/// Normal test runs skip the network test. Set the `KEYJAWN_LIVE_SSH_*` +/// environment variables described in `docs/ios-app-review.md` to enable it. +@MainActor +final class SSHLiveIntegrationTests: XCTestCase { + + func testLiveConfigurationRequiresAnExplicitTLSChoice() throws { + let environment = liveEnvironment() + + XCTAssertThrowsError( + try LiveSSHConfiguration.fromEnvironment(environment) + ) { error in + guard case LiveSSHTestError.incompleteConfiguration = error else { + return XCTFail("Expected incompleteConfiguration, got \(error)") + } + } + } + + func testLiveConfigurationRejectsAnInvalidTLSChoice() throws { + var environment = liveEnvironment() + environment["KEYJAWN_LIVE_SSH_TLS"] = "true" + + XCTAssertThrowsError( + try LiveSSHConfiguration.fromEnvironment(environment) + ) { error in + guard case LiveSSHTestError.invalidTLSValue("true") = error else { + return XCTFail("Expected invalidTLSValue, got \(error)") + } + } + } + + func testLiveConfigurationEnablesTLSOnlyForOne() throws { + var environment = liveEnvironment() + environment["KEYJAWN_LIVE_SSH_TLS"] = "1" + + XCTAssertTrue( + try XCTUnwrap( + LiveSSHConfiguration.fromEnvironment(environment) + ).usesTLSTunnel + ) + + environment["KEYJAWN_LIVE_SSH_TLS"] = "0" + XCTAssertFalse( + try XCTUnwrap( + LiveSSHConfiguration.fromEnvironment(environment) + ).usesTLSTunnel + ) + } + + func testShellSingleQuoteEscapesApostrophes() { + XCTAssertEqual( + shellSingleQuoted("/tmp/reviewer's image.jpg"), + "'/tmp/reviewer'\\''s image.jpg'" + ) + } + + func testIdentityKeyUsesAuthorizedKeysFormat() throws { + let publicKey = SSHKeyStore.shared.publicKeyOpenSSHString + let parts = publicKey.split(separator: " ") + + XCTAssertEqual(parts.count, 3) + guard parts.count == 3 else { return } + XCTAssertEqual(parts.first, "ssh-ed25519") + XCTAssertNotNil(Data(base64Encoded: String(parts[1]))) + XCTAssertEqual(parts.last, "keyjawn") + + if ProcessInfo.processInfo.environment["KEYJAWN_EXPORT_PUBLIC_KEY"] == "1" { + print("KEYJAWN_LIVE_PUBLIC_KEY=\(publicKey)") + } + } + + func testHostTrustRemotePTYAndSFTPUpload() async throws { + guard let configuration = try LiveSSHConfiguration.fromEnvironment() else { + throw XCTSkip( + "Set KEYJAWN_LIVE_SSH_HOST, USER, HOST_KEY, and TLS to run the live SSH test" + ) + } + + var host = HostConfig( + label: "Live integration", + hostname: configuration.host, + port: configuration.port, + username: configuration.user, + authMethod: .key, + usesTLSTunnel: configuration.usesTLSTunnel, + uploadPath: configuration.uploadPath + ) + let session = SSHSession() + var output: [UInt8] = [] + session.onData = { output.append(contentsOf: $0) } + + session.connectWithKey(to: host) + try await waitForState(.awaitingHostKey, in: session) + + let presentedKey = try XCTUnwrap(session.pendingHostKey) + XCTAssertTrue(try presentedKey.matches(openSSHKey: configuration.hostKey)) + host.hostPublicKey = presentedKey.openSSHKey + + session.connectAfterTrust(to: host) + try await waitForState(.connected, in: session) + + let token = UUID().uuidString.lowercased() + let expectedOutput = "KEYJAWN_\(token)" + session.send(Array("printf 'KEYJAWN_%s\\n' '\(token)'\n".utf8)) + try await waitUntil("remote PTY output") { + String(decoding: output, as: UTF8.self).contains(expectedOutput) + } + session.resize(cols: 100, rows: 30) + session.disconnect() + + let payload = Data("keyjawn-sftp-\(token)\n".utf8) + let remotePath = try await CitadelSCPUploader.upload( + imageData: payload, + to: host, + privateKeyData: SSHKeyStore.shared.privateKey.rawRepresentation + ) + XCTAssertTrue(remotePath.hasPrefix(configuration.uploadPath + "/keyjawn-")) + + let cleanupMarker = "KEYJAWN_LIVE_CLEANED_\(token)" + let failureMarker = "KEYJAWN_LIVE_FAILED_\(token)" + let quotedPath = shellSingleQuoted(remotePath) + output.removeAll() + session.connectWithKey(to: host) + try await waitForState(.connected, in: session) + let cleanupCommand = + "actual=$(cat -- \(quotedPath)); " + + "if [ \"$actual\" = \"keyjawn-sftp-\(token)\" ] " + + "&& [ \"$(wc -c < \(quotedPath))\" -eq \(payload.count) ] " + + "&& rm -f -- \(quotedPath) && [ ! -e \(quotedPath) ]; " + + "then printf 'KEYJAWN_LIVE_%s_%s\\n' 'CLEANED' '\(token)'; " + + "else printf 'KEYJAWN_LIVE_%s_%s\\n' 'FAILED' '\(token)'; fi\n" + session.send(Array(cleanupCommand.utf8)) + try await waitUntil("remote upload verification and cleanup") { + let text = String(decoding: output, as: UTF8.self) + return text.contains(cleanupMarker) || text.contains(failureMarker) + } + XCTAssertTrue( + String(decoding: output, as: UTF8.self).contains(cleanupMarker), + "remote payload must match and its file must be removed before the live test passes" + ) + session.disconnect() + } + + private func waitForState( + _ expected: SSHSession.ConnectionState, + in session: SSHSession + ) async throws { + try await waitUntil("SSH state \(expected)") { + if case .failed = session.connectionState { return true } + return session.connectionState == expected + } + if case .failed(let message) = session.connectionState { + throw LiveSSHTestError.connectionFailed(message) + } + } + + private func waitUntil( + _ operation: String, + timeout: TimeInterval = 30, + condition: () -> Bool + ) async throws { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { return } + try await Task.sleep(for: .milliseconds(100)) + } + throw LiveSSHTestError.timedOut(operation) + } + + private func liveEnvironment() -> [String: String] { + [ + "KEYJAWN_LIVE_SSH_HOST": "review.example", + "KEYJAWN_LIVE_SSH_USER": "reviewer", + "KEYJAWN_LIVE_SSH_HOST_KEY": "ssh-ed25519 AAAAexample", + ] + } +} + +private struct LiveSSHConfiguration { + let host: String + let user: String + let port: UInt16 + let hostKey: String + let uploadPath: String + let usesTLSTunnel: Bool + + static func fromEnvironment( + _ environment: [String: String] = ProcessInfo.processInfo.environment + ) throws -> Self? { + let names = [ + "KEYJAWN_LIVE_SSH_HOST", + "KEYJAWN_LIVE_SSH_USER", + "KEYJAWN_LIVE_SSH_HOST_KEY", + "KEYJAWN_LIVE_SSH_TLS", + ] + let present = names.filter { environment[$0]?.isEmpty == false } + if present.isEmpty { return nil } + guard present.count == names.count else { + throw LiveSSHTestError.incompleteConfiguration + } + + let portText = environment["KEYJAWN_LIVE_SSH_PORT"] ?? "22" + guard let port = UInt16(portText), port > 0 else { + throw LiveSSHTestError.invalidPort(portText) + } + let uploadPath = environment["KEYJAWN_LIVE_SSH_UPLOAD_PATH"] ?? "/tmp" + guard uploadPath.hasPrefix("/"), !uploadPath.contains("\n") else { + throw LiveSSHTestError.invalidUploadPath(uploadPath) + } + let tlsText = environment["KEYJAWN_LIVE_SSH_TLS"]! + guard tlsText == "0" || tlsText == "1" else { + throw LiveSSHTestError.invalidTLSValue(tlsText) + } + + return Self( + host: environment["KEYJAWN_LIVE_SSH_HOST"]!, + user: environment["KEYJAWN_LIVE_SSH_USER"]!, + port: port, + hostKey: environment["KEYJAWN_LIVE_SSH_HOST_KEY"]!, + uploadPath: uploadPath, + usesTLSTunnel: tlsText == "1" + ) + } +} + +private enum LiveSSHTestError: Error, LocalizedError { + case incompleteConfiguration + case invalidPort(String) + case invalidUploadPath(String) + case invalidTLSValue(String) + case connectionFailed(String) + case timedOut(String) + + var errorDescription: String? { + switch self { + case .incompleteConfiguration: + return "The live SSH environment is incomplete" + case .invalidPort(let value): + return "Invalid live SSH port: \(value)" + case .invalidUploadPath(let value): + return "Invalid live SSH upload path: \(value)" + case .invalidTLSValue(let value): + return "Invalid live SSH TLS value: \(value). Use 0 or 1" + case .connectionFailed(let message): + return "SSH connection failed: \(message)" + case .timedOut(let operation): + return "Timed out waiting for \(operation)" + } + } +} + +private func shellSingleQuoted(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" +} diff --git a/ios/Tests/SlashScreenshotRenderTests.swift b/ios/Tests/SlashScreenshotRenderTests.swift index b996cb8..5b4a050 100644 --- a/ios/Tests/SlashScreenshotRenderTests.swift +++ b/ios/Tests/SlashScreenshotRenderTests.swift @@ -1,5 +1,6 @@ import UIKit import XCTest + @testable import KeyJawnKit /// Renders the real extra row, number row, QWERTY, and slash panel onto a @@ -21,7 +22,8 @@ final class SlashScreenshotRenderTests: XCTestCase { // Opt-in only. Ordinary local `xcodebuild test` must not rewrite the // tracked listing PNG. if let dest = ProcessInfo.processInfo.environment["SLASH_SCREENSHOT_PATH"], - !dest.isEmpty { + !dest.isEmpty + { try data.write(to: URL(fileURLWithPath: dest)) } } @@ -42,10 +44,10 @@ final class SlashScreenshotRenderTests: XCTestCase { prompt.font = .monospacedSystemFont(ofSize: 15, weight: .regular) prompt.textColor = UIColor(white: 0.78, alpha: 1) prompt.text = """ - user@host:~$ ls - docs src README.md - user@host:~$ - """ + reviewer@remote-host:~$ printf 'remote-ok\n' + remote-ok + reviewer@remote-host:~$ + """ prompt.frame = CGRect(x: 16, y: 64, width: pointSize.width - 32, height: 200) root.addSubview(prompt) @@ -65,7 +67,7 @@ final class SlashScreenshotRenderTests: XCTestCase { root.addSubview(qwerty) let panel = SlashCommandPanel(theme: theme) - // Cover the QWERTY so the panel — the 3.2.2 artifact — is the subject. + // Cover the QWERTY so the slash panel is the subject. panel.frame = CGRect(x: 8, y: extra.frame.minY - 280, width: pointSize.width - 16, height: 360) panel.layer.cornerRadius = 10 panel.clipsToBounds = true diff --git a/ios/Tests/TerminalInputMappingTests.swift b/ios/Tests/TerminalInputMappingTests.swift index 1d568b5..cefd76e 100644 --- a/ios/Tests/TerminalInputMappingTests.swift +++ b/ios/Tests/TerminalInputMappingTests.swift @@ -1,5 +1,6 @@ import UIKit import XCTest + @testable import KeyJawn @testable import KeyJawnKit @@ -67,23 +68,6 @@ final class TerminalInputViewTests: XCTestCase { XCTAssertEqual(got.first, [0x0d]) } - func testInsertNewlineWithoutSubmitIsNotSubmit() { - let sink = TerminalInputView() - var got: [[UInt8]] = [] - sink.onRawInput = { got.append($0) } - sink.insertNewlineWithoutSubmit() - XCTAssertEqual(got, [TerminalInputMapping.newlineBytes]) - XCTAssertNotEqual(got.first, TerminalInputMapping.submitBytes) - } - - func testSubmitLineWritesCR() { - let sink = TerminalInputView() - var got: [[UInt8]] = [] - sink.onRawInput = { got.append($0) } - sink.submitLine() - XCTAssertEqual(got, [[0x0d]]) - } - func testTypedTextIsUTF8() { let sink = TerminalInputView() var got: [[UInt8]] = [] @@ -160,9 +144,11 @@ final class TerminalInputViewTests: XCTestCase { tapExtra(extra, accessibility: "Slash commands") - guard let panel = window.subviews.reversed().first(where: { - $0.accessibilityIdentifier == "slash-command-panel" - }) as? SlashCommandPanel else { + guard + let panel = window.subviews.reversed().first(where: { + $0.accessibilityIdentifier == "slash-command-panel" + }) as? SlashCommandPanel + else { return XCTFail("slash panel did not appear over the terminal") } @@ -212,7 +198,7 @@ final class TerminalInputViewTests: XCTestCase { window.isHidden = true } - func testSCPOpensUploadPanel() { + func testUploadButtonOpensUploadPanel() { let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 700)) let sink = TerminalInputView(frame: window.bounds) sink.uploadHosts = { [] } @@ -224,7 +210,7 @@ final class TerminalInputViewTests: XCTestCase { window.addSubview(extra) extra.layoutIfNeeded() - tapExtra(extra, accessibility: "Upload image over SFTP") + tapExtra(extra, accessibility: "Upload copied image to remote SSH host") XCTAssertTrue( window.subviews.contains { $0.accessibilityIdentifier == "upload-panel" }, "SCP must present UploadPanel, not a no-op" @@ -232,12 +218,141 @@ final class TerminalInputViewTests: XCTestCase { window.isHidden = true } + func testUploadPanelExcludesPasswordAuthenticatedHosts() { + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 700)) + let sink = TerminalInputView(frame: window.bounds) + let keyHost = HostConfig( + label: "key", + hostname: "key.internal", + username: "reviewer", + authMethod: .key, + hostPublicKey: + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJfkNV4OS33ImTXvorZr72q4v5XhVEQKfvqsxOEJ/XaR" + ) + let passwordHost = HostConfig( + label: "password", + hostname: "password.internal", + username: "reviewer", + authMethod: .password + ) + sink.uploadHosts = { [keyHost, passwordHost] } + window.addSubview(sink) + window.makeKeyAndVisible() + + let extra = sink.extraRow + extra.frame = CGRect(x: 0, y: 640, width: 390, height: 52) + window.addSubview(extra) + extra.layoutIfNeeded() + + tapExtra(extra, accessibility: "Upload copied image to remote SSH host") + let panel = window.subviews.compactMap { $0 as? UploadPanel }.first + XCTAssertEqual(panel?.hosts, [keyHost]) + window.isHidden = true + } + + func testUploadPanelNamesTheCopiedImageAndRemoteHost() { + let panel = UploadPanel() + let labels = panel.subviews + .flatMap { [$0] + $0.subviews } + .compactMap { $0 as? UILabel } + .compactMap(\.text) + + XCTAssertTrue(labels.contains("Upload copied image")) + XCTAssertTrue( + UploadPanel.EmptyReason.noHostsConfigured.message.contains("remote SSH host") + ) + XCTAssertTrue( + UploadPanel.EmptyReason.keyAuthenticationRequired.message.contains( + "SSH key authentication" + ) + ) + XCTAssertTrue( + UploadPanel.EmptyReason.hostKeyVerificationRequired.message.contains( + "verify its SSH host key" + ) + ) + XCTAssertFalse( + UploadPanel.EmptyReason.noHostsConfigured.message.contains("main app") + ) + XCTAssertFalse( + UploadPanel.EmptyReason.keyAuthenticationRequired.message.contains("main app") + ) + } + + func testUploadPanelCanDisableDismissDuringRemoteWrite() { + let panel = UploadPanel() + let cancel = panel.subviews + .flatMap { [$0] + $0.subviews } + .compactMap { $0 as? UIButton } + .first { $0.currentTitle == "Cancel" } + + panel.isDismissEnabled = false + + XCTAssertFalse(cancel?.isEnabled ?? true) + } + + func testUploadExtraRowCannotDismissPanelDuringRemoteWrite() { + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 700)) + let sink = TerminalInputView(frame: window.bounds) + sink.uploadHosts = { [] } + window.addSubview(sink) + window.makeKeyAndVisible() + + let extra = sink.extraRow + extra.frame = CGRect(x: 0, y: 640, width: 390, height: 52) + window.addSubview(extra) + extra.layoutIfNeeded() + + tapExtra(extra, accessibility: "Upload copied image to remote SSH host") + let panel = window.subviews.compactMap { $0 as? UploadPanel }.first + XCTAssertNotNil(panel) + panel?.isDismissEnabled = false + + tapExtra(extra, accessibility: "Upload copied image to remote SSH host") + + XCTAssertTrue( + window.subviews.contains { $0 === panel }, + "the extra-row upload key must not cancel an active remote write" + ) + window.isHidden = true + } + + func testUploadPanelNamesAnUnverifiedKeyHost() { + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 700)) + let sink = TerminalInputView(frame: window.bounds) + let unverifiedHost = HostConfig( + label: "remote", + hostname: "remote.internal", + username: "reviewer", + authMethod: .key + ) + sink.uploadHosts = { [unverifiedHost] } + window.addSubview(sink) + window.makeKeyAndVisible() + + let extra = sink.extraRow + extra.frame = CGRect(x: 0, y: 640, width: 390, height: 52) + window.addSubview(extra) + extra.layoutIfNeeded() + + tapExtra(extra, accessibility: "Upload copied image to remote SSH host") + + let panel = window.subviews.compactMap { $0 as? UploadPanel }.first + XCTAssertEqual( + panel?.emptyReason.message, + UploadPanel.EmptyReason.hostKeyVerificationRequired.message + ) + window.isHidden = true + } + private func tapExtra(_ extra: ExtraRowView, accessibility: String) { let button = extra.subviews .flatMap(\.subviews) .compactMap { $0 as? UIButton } - .first { $0.accessibilityLabel?.contains(accessibility) == true - || $0.currentTitle == accessibility } + .first { + $0.accessibilityLabel?.contains(accessibility) == true + || $0.currentTitle == accessibility + } XCTAssertNotNil(button, "missing extra-row control \(accessibility)") button?.sendActions(for: .touchUpInside) } diff --git a/ios/UITests/KeyboardIMETests.swift b/ios/UITests/KeyboardIMETests.swift index 69558e0..eba7a3b 100644 --- a/ios/UITests/KeyboardIMETests.swift +++ b/ios/UITests/KeyboardIMETests.swift @@ -1,22 +1,42 @@ +import UIKit import XCTest -/// Enables the KeyJawn keyboard extension the way a user does — Settings — -/// then types into Notes. This is the system IME path, not the in-app extra row. +/// Enables the KeyJawn keyboard extension in Settings, selects an explicit Full +/// Access state, and types into an active text field through the system IME. +@MainActor final class KeyboardIMETests: XCTestCase { + private var keyboardWasInstalled: Bool? + private var initialFullAccess: Bool? + override func setUpWithError() throws { continueAfterFailure = false } - func testEnableKeyJawnKeyboardAndTypeInNotes() throws { + func testSystemKeyboardTypesWithFullAccessOff() throws { + defer { restoreKeyboardState() } installContainingApp() - try enableKeyJawnInSettings() - try typeHelloInNotes() + let settings = XCUIApplication(bundleIdentifier: "com.apple.Preferences") + try setFullAccess(false, in: settings) + typeHello( + in: settings, + screenshotName: "05-companion-keyboard", + verifyBuiltInShortcut: true + ) + } + + func testSystemKeyboardTypesWithFullAccessOn() throws { + defer { restoreKeyboardState() } + installContainingApp() + let settings = XCUIApplication(bundleIdentifier: "com.apple.Preferences") + try setFullAccess(true, in: settings) + typeHello(in: settings) } private func installContainingApp() { // Installing the host app also installs the keyboard appex. let app = XCUIApplication() + app.launchArguments = ["-ui-testing"] app.launch() if app.buttons["onboarding.skip"].waitForExistence(timeout: 4) { app.buttons["onboarding.skip"].tap() @@ -24,16 +44,110 @@ final class KeyboardIMETests: XCTestCase { app.terminate() } - private func enableKeyJawnInSettings() throws { + private func setFullAccess(_ enabled: Bool, in settings: XCUIApplication) throws { + settings.launch() + try openKeyboardList(in: settings) + keyboardWasInstalled = settings.cells["com.keyjawn.keyboard"].exists + installKeyboardIfNeeded(in: settings) + try openInstalledKeyboard(in: settings) + let toggle = settings.switches["Allow Full Access"] + XCTAssertTrue(toggle.waitForExistence(timeout: 5), "Allow Full Access switch") + if keyboardWasInstalled == true { + initialFullAccess = toggle.value as? String == "1" + } + setFullAccessToggle(enabled, in: settings) + settings.terminate() + } + + private func restoreKeyboardState() { + guard let keyboardWasInstalled else { return } + let settings = XCUIApplication(bundleIdentifier: "com.apple.Preferences") settings.launch() + defer { settings.terminate() } + + do { + try openKeyboardList(in: settings) + if keyboardWasInstalled { + guard let initialFullAccess else { return } + try openInstalledKeyboard(in: settings) + setFullAccessToggle(initialFullAccess, in: settings) + } else { + removeInstalledKeyboard(in: settings) + } + } catch { + XCTFail("Restore the pre-test keyboard state: \(error)") + } + } + + private func openInstalledKeyboard(in settings: XCUIApplication) throws { + let installedKeyboard = settings.cells["com.keyjawn.keyboard"] + XCTAssertTrue( + installedKeyboard.waitForExistence(timeout: 5), + "KeyJawn must appear in the installed keyboard list" + ) + installedKeyboard.tap() + } + + private func setFullAccessToggle(_ enabled: Bool, in settings: XCUIApplication) { + let toggle = settings.switches["Allow Full Access"] + XCTAssertTrue(toggle.waitForExistence(timeout: 5), "Allow Full Access switch") + let expectedValue = enabled ? "1" : "0" + if toggle.value as? String != expectedValue { + toggle.coordinate(withNormalizedOffset: CGVector(dx: 0.95, dy: 0.5)).tap() + if enabled { + confirmFullAccessIfNeeded(in: settings) + } + } + + let valueExpectation = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "value == %@", expectedValue), + object: toggle + ) + XCTAssertEqual( + XCTWaiter.wait(for: [valueExpectation], timeout: 5), + .completed, + "Full Access must be \(enabled ? "on" : "off")" + ) + } + + private func confirmFullAccessIfNeeded(in settings: XCUIApplication) { + let alert = settings.alerts.firstMatch + guard alert.waitForExistence(timeout: 5) else { return } + + let allow = alert.buttons["Allow"] + let allowFullAccess = alert.buttons["Allow Full Access"] + let confirmation = allow.exists ? allow : allowFullAccess + XCTAssertTrue(confirmation.waitForExistence(timeout: 2), "Full Access confirmation") + confirmation.tap() + + let dismissed = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "exists == false"), + object: alert + ) + XCTAssertEqual( + XCTWaiter.wait(for: [dismissed], timeout: 5), + .completed, + "Full Access confirmation must close" + ) + } + + private func openKeyboardList(in settings: XCUIApplication) throws { + if settings.cells["com.keyjawn.keyboard"].exists + || settings.cells["AddNewKeyboard"].exists + { + return + } if settings.tables.staticTexts["General"].waitForExistence(timeout: 6) { settings.tables.staticTexts["General"].tap() } else if settings.buttons["General"].waitForExistence(timeout: 2) { settings.buttons["General"].tap() + } else if settings.staticTexts["General"].firstMatch.waitForExistence(timeout: 2) { + settings.staticTexts["General"].firstMatch.tap() } else { - throw XCTSkip("Settings General row not found on this OS") + XCTFail("Settings General row not found on this supported OS") + return } let keyboardRow = firstMatch(in: settings, labels: ["Keyboard", "Keyboards"]) @@ -51,145 +165,353 @@ final class KeyboardIMETests: XCTestCase { settings.cells.containing(.staticText, identifier: "Keyboards").firstMatch.tap() } - if settings.staticTexts["KeyJawn Keyboard"].waitForExistence(timeout: 2) - || settings.staticTexts["KeyJawn"].waitForExistence(timeout: 1) { - allowFullAccessIfPresent(in: settings) - settings.terminate() - return - } + XCTAssertTrue( + settings.cells["com.keyjawn.keyboard"].waitForExistence(timeout: 2) + || settings.cells["AddNewKeyboard"].waitForExistence(timeout: 2), + "installed keyboard list" + ) + } - let add = firstMatch(in: settings, labels: [ - "Add New Keyboard…", - "Add New Keyboard...", - "Add Keyboard", - ]) - // iOS 26 Settings: after opening cells["KEYBOARDS"], none of the - // historical Add New Keyboard labels exist. Do not fail the suite — - // the insert-path unit tests prove the shipped mapping. - guard add.waitForExistence(timeout: 5) else { - throw XCTSkip("Settings chrome blocker: after cells[KEYBOARDS], no Add New Keyboard… / Add New Keyboard... / Add Keyboard") - } + private func installKeyboardIfNeeded(in settings: XCUIApplication) { + let installedKeyboard = settings.cells["com.keyjawn.keyboard"] + if installedKeyboard.exists { return } + + let addByIdentifier = settings.cells["AddNewKeyboard"] + let add = + addByIdentifier.exists + ? addByIdentifier + : firstMatch( + in: settings, + labels: [ + "Add New Keyboard", + "Add New Keyboard…", + "Add New Keyboard...", + "Add Keyboard", + ]) + XCTAssertTrue(add.waitForExistence(timeout: 5), "Add New Keyboard row") add.tap() - let keyjawn = firstMatch(in: settings, labels: ["KeyJawn Keyboard", "KeyJawn"]) + let keyjawnByIdentifier = settings.staticTexts["com.keyjawn"] + let keyjawn = + keyjawnByIdentifier.exists + ? keyjawnByIdentifier + : firstMatch(in: settings, labels: ["KeyJawn Keyboard", "KeyJawn"]) XCTAssertTrue(keyjawn.waitForExistence(timeout: 6), "KeyJawn in the add-keyboard list") keyjawn.tap() - if settings.switches["Allow Full Access"].waitForExistence(timeout: 3) { - let toggle = settings.switches["Allow Full Access"] - if toggle.value as? String != "1" { - toggle.tap() - let allow = firstMatch(in: settings, labels: ["Allow", "Add Keyboard"]) - if allow.waitForExistence(timeout: 3) { allow.tap() } - } - } else { - let addKeyboard = firstMatch(in: settings, labels: ["Add Keyboard", "Allow"]) - if addKeyboard.waitForExistence(timeout: 3) { addKeyboard.tap() } + let addKeyboard = firstMatch(in: settings, labels: ["Add Keyboard", "Allow"]) + if addKeyboard.waitForExistence(timeout: 2) { + addKeyboard.tap() } - - allowFullAccessIfPresent(in: settings) - settings.terminate() + if !installedKeyboard.waitForExistence(timeout: 5), + settings.navigationBars.buttons["Keyboards"].exists + { + settings.navigationBars.buttons["Keyboards"].tap() + } + XCTAssertTrue(installedKeyboard.waitForExistence(timeout: 5), "installed KeyJawn keyboard") } - private func allowFullAccessIfPresent(in settings: XCUIApplication) { - if settings.staticTexts["KeyJawn Keyboard"].waitForExistence(timeout: 2) { - settings.staticTexts["KeyJawn Keyboard"].tap() - } else if settings.staticTexts["KeyJawn"].waitForExistence(timeout: 1) { - settings.staticTexts["KeyJawn"].tap() - } - if settings.switches["Allow Full Access"].waitForExistence(timeout: 2) { - let toggle = settings.switches["Allow Full Access"] - if toggle.value as? String != "1" { - toggle.tap() - let allow = firstMatch(in: settings, labels: ["Allow", "Allow Full Access"]) - if allow.waitForExistence(timeout: 3) { allow.tap() } + private func removeInstalledKeyboard(in settings: XCUIApplication) { + let installedKeyboard = settings.cells["com.keyjawn.keyboard"] + guard installedKeyboard.waitForExistence(timeout: 3) else { return } + + // A fast swipe can be ignored by the iPad Settings split view. Use + // the explicit edit control first and keep the swipe for older pages. + let keyboardNavigationBar = settings.navigationBars["Keyboards"] + let edit = keyboardNavigationBar.buttons["Edit"] + var deleteWasRevealed = false + if edit.waitForExistence(timeout: 3) { + edit.tap() + let removeKeyJawn = settings.buttons.matching( + NSPredicate( + format: "label CONTAINS[c] %@ AND label CONTAINS[c] %@", + "Remove", + "KeyJawn" + ) + ).firstMatch + if removeKeyJawn.waitForExistence(timeout: 3) { + removeKeyJawn.tap() + deleteWasRevealed = true + } else { + let done = keyboardNavigationBar.buttons["Done"] + XCTAssertTrue(done.waitForExistence(timeout: 2), "Exit keyboard edit mode") + done.tap() } } - } + if !deleteWasRevealed { + installedKeyboard.swipeLeft() + } - private func typeHelloInNotes() throws { - let notes = XCUIApplication(bundleIdentifier: "com.apple.mobilenotes") - notes.launch() + let delete = settings.buttons["Delete"].firstMatch + XCTAssertTrue(delete.waitForExistence(timeout: 3), "Delete installed keyboard button") + delete.tap() - let compose = firstMatch(in: notes, labels: ["New Note", "Compose", "New note"]) - if compose.waitForExistence(timeout: 5) { - compose.tap() - } else if notes.buttons.matching(NSPredicate(format: "label CONTAINS[c] 'note'")).firstMatch.waitForExistence(timeout: 3) { - notes.buttons.matching(NSPredicate(format: "label CONTAINS[c] 'note'")).firstMatch.tap() - } else { - throw XCTSkip("Notes compose control not found") + let removed = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "exists == false"), + object: installedKeyboard + ) + XCTAssertEqual( + XCTWaiter.wait(for: [removed], timeout: 5), + .completed, + "KeyJawn keyboard must return to its pre-test uninstalled state" + ) + let done = keyboardNavigationBar.buttons["Done"] + if done.waitForExistence(timeout: 2) { + done.tap() } + } - let field = notes.textViews.firstMatch - XCTAssertTrue(field.waitForExistence(timeout: 5), "Notes text view") - field.tap() + private func typeHello( + in settings: XCUIApplication, + screenshotName: String? = nil, + verifyBuiltInShortcut: Bool = false + ) { + settings.terminate() + typeHelloInHostLabel( + screenshotName: screenshotName, + verifyBuiltInShortcut: verifyBuiltInShortcut + ) + } + + private func typeHelloInHostLabel( + screenshotName: String?, + verifyBuiltInShortcut: Bool + ) { + let app = XCUIApplication() + app.launchArguments = ["-ui-testing"] + app.launch() + if app.buttons["onboarding.skip"].waitForExistence(timeout: 4) { + app.buttons["onboarding.skip"].tap() + } - switchToKeyJawn(in: notes) + let addHost = app.buttons["Add host"].firstMatch + XCTAssertTrue(addHost.waitForExistence(timeout: 5), "Add host button") + addHost.tap() + let field = app.textFields["e.g. remote server"] + XCTAssertTrue(field.waitForExistence(timeout: 5), "host label field") XCTAssertTrue( - extraRowIsVisible(in: notes, wait: 4), - "KeyJawn extra-row Control must be visible before letter taps" + app.switches["TLS tunnel"].waitForExistence(timeout: 3), + "the screenshot host form must include the current TLS tunnel control" ) - tapKey(in: notes, "h") - tapKey(in: notes, "i") + typeHello( + in: field, + app: app, + screenshotName: screenshotName, + verifyBuiltInShortcut: verifyBuiltInShortcut + ) + } + + private func typeHello( + in field: XCUIElement, + app: XCUIApplication, + screenshotName: String?, + verifyBuiltInShortcut: Bool + ) { + field.tap() + clear(field) + XCTAssertTrue( - notes.textViews.firstMatch.value as? String == "hi" - || (notes.textViews.firstMatch.value as? String)?.contains("hi") == true, - "typed hi via the software keyboard" + switchToKeyJawn(in: app), + "KeyJawn must be the selected system keyboard before letter taps" + ) + assertLeadingExtraRowKeysAreHittable(in: app) + if let screenshotName { + attachScreenshot(named: screenshotName) + } + tapKey(in: app, "h") + tapKey(in: app, "i") + let typedValue = (field.value as? String)?.lowercased() + XCTAssertEqual( + typedValue, + "hi", + "typed hi through the KeyJawn system keyboard" ) - notes.terminate() + var expectedValue = "hi" + if UIDevice.current.userInterfaceIdiom == .pad { + tapKey(in: app, "Shift") + let systemKeyboards = app.otherElements.matching(identifier: Self.systemKeyboardId) + let shiftedQ = systemKeyboards.buttons["Q"].firstMatch + XCTAssertTrue(shiftedQ.waitForExistence(timeout: 2), "shifted Q key") + let flickStart = shiftedQ.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15)) + let flickEnd = shiftedQ.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.9)) + flickStart.press(forDuration: 0.01, thenDragTo: flickEnd) + expectedValue += "1" + XCTAssertEqual( + (field.value as? String)?.lowercased(), + expectedValue, + "downward Q flick inserts its secondary character once" + ) + + let lowercaseQ = systemKeyboards.buttons["q"].firstMatch + XCTAssertTrue( + lowercaseQ.waitForExistence(timeout: 2), + "a flick must consume one-shot Shift" + ) + lowercaseQ.tap() + expectedValue += "q" + XCTAssertEqual( + (field.value as? String)?.lowercased(), + expectedValue, + "a normal tap still inserts the primary character once" + ) + } + if verifyBuiltInShortcut { + let slash = app.buttons["extra.slash"].firstMatch + XCTAssertTrue(slash.waitForExistence(timeout: 3), "built-in slash key") + slash.tap() + + let compact = app.staticTexts["/compact"].firstMatch + XCTAssertTrue(compact.waitForExistence(timeout: 3), "built-in /compact shortcut") + compact.tap() + XCTAssertEqual( + (field.value as? String)?.lowercased(), + expectedValue + "/compact", + "built-in shortcut must work with Full Access off" + ) + } + app.terminate() + } + + private func attachScreenshot(named name: String) { + let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } + + private func clear(_ field: XCUIElement) { + guard let value = field.value as? String, + value != field.placeholderValue, + !value.isEmpty + else { return } + + field.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: value.count)) } /// ExtraRowButton exposes identifier `extra.ctrlC` and the spoken Control-C /// label, not the visible `^C` title. XCUIElement queries match those. private static let extraRowCtrlCId = "extra.ctrlC" - private static let extraRowCtrlCLabel = - "Control C. Double tap and hold to lock the Control modifier." + private static let extraRowTabId = "extra.tab" + private static let systemKeyboardId = "keyjawn.systemKeyboard" + private static let systemAssistantButtonIds = [ + "assistantUndo", + "assistantRedo", + "assistantPaste:forEvent:", + ] + + /// iPadOS can place its undo, redo, and paste assistant controls over the + /// keyboard's leading edge. Existence alone did not catch that regression: + /// the covered KeyJawn buttons remained in the accessibility tree but could + /// not receive a tap. + private func assertLeadingExtraRowKeysAreHittable(in app: XCUIApplication) { + let systemKeyboards = app.otherElements.matching(identifier: Self.systemKeyboardId) + let controls = [ + systemKeyboards.buttons[Self.extraRowCtrlCId].firstMatch, + systemKeyboards.buttons[Self.extraRowTabId].firstMatch, + ] + + let assistantButtons = Self.systemAssistantButtonIds + .map { app.buttons[$0].firstMatch } + .filter(\.exists) + if let assistantTrailingEdge = assistantButtons.map(\.frame.maxX).max() { + for control in controls { + XCTAssertGreaterThanOrEqual( + control.frame.minX, + assistantTrailingEdge, + "KeyJawn's leading extra-row controls must start after the iPad system assistant group" + ) + } + } + + for control in controls { + XCTAssertTrue(control.waitForExistence(timeout: 2), "leading extra-row control") + XCTAssertTrue( + control.isHittable, + "iPad system assistant controls must not cover KeyJawn's leading extra-row controls" + ) + } + } private func extraRowIsVisible(in app: XCUIApplication, wait: TimeInterval = 0) -> Bool { - let deadline = Date().addingTimeInterval(wait) - repeat { - if extraRowElement(in: app).exists { return true } - if wait > 0 { RunLoop.current.run(until: Date().addingTimeInterval(0.2)) } - } while Date() < deadline - return extraRowElement(in: app).exists - } - - private func extraRowElement(in app: XCUIApplication) -> XCUIElement { - let byId = app.buttons[Self.extraRowCtrlCId] - if byId.exists { return byId } - let keyById = app.keys[Self.extraRowCtrlCId] - if keyById.exists { return keyById } - let byLabel = app.buttons[Self.extraRowCtrlCLabel] - if byLabel.exists { return byLabel } - return app.keys[Self.extraRowCtrlCLabel] - } - - private func switchToKeyJawn(in app: XCUIApplication) { - let globe = firstMatch(in: app, labels: ["Next Keyboard", "Globe", "Emoji"]) + let systemKeyboards = app.otherElements.matching(identifier: Self.systemKeyboardId) + let extraRow = systemKeyboards.buttons[Self.extraRowCtrlCId].firstMatch + return wait > 0 ? extraRow.waitForExistence(timeout: wait) : extraRow.exists + } + + private func switchToKeyJawn(in app: XCUIApplication) -> Bool { + if extraRowIsVisible(in: app) { return true } + let selector = app.buttons["emoji"] + if selector.exists, selector.isHittable { + selector.press(forDuration: 1) + if dismissKeyboardPickerEducationIfNeeded(in: app) { + selector.tap() + if extraRowIsVisible(in: app, wait: 5) { return true } + selector.press(forDuration: 1) + } + if selectKeyJawnFromPicker(in: app) { + return extraRowIsVisible(in: app, wait: 5) + } + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.2)).tap() + } + for _ in 0..<6 { - if extraRowIsVisible(in: app) { return } - if globe.exists { globe.tap(); continue } - if app.buttons["Next Keyboard"].exists { app.buttons["Next Keyboard"].tap(); continue } - break + if extraRowIsVisible(in: app) { return true } + let byIdentifier = app.buttons["emoji"] + if byIdentifier.exists, byIdentifier.isHittable { + byIdentifier.tap() + } else { + let byLabel = firstMatch( + in: app, + labels: ["Next keyboard", "Next Keyboard", "Globe", "Emoji"] + ) + guard byLabel.exists, byLabel.isHittable else { return false } + byLabel.tap() + } + if dismissKeyboardPickerEducationIfNeeded(in: app) { continue } + if extraRowIsVisible(in: app, wait: 3) { return true } } + return false } - private func tapKey(in app: XCUIApplication, _ label: String) { - if app.keys[label].exists { - app.keys[label].tap() - } else { - app.buttons[label].tap() + private func dismissKeyboardPickerEducationIfNeeded(in app: XCUIApplication) -> Bool { + let title = app.staticTexts["Quickly Change Keyboards"] + guard title.waitForExistence(timeout: 2) else { return false } + + let continueButton = app.buttons["Continue"] + XCTAssertTrue(continueButton.waitForExistence(timeout: 2), "keyboard picker education") + continueButton.tap() + return true + } + + private func selectKeyJawnFromPicker(in app: XCUIApplication) -> Bool { + let predicate = NSPredicate(format: "label BEGINSWITH %@", "KeyJawn Keyboard") + let candidates = [ + app.buttons.matching(predicate).firstMatch, + app.menuItems.matching(predicate).firstMatch, + app.otherElements.matching(predicate).firstMatch, + ] + for candidate in candidates where candidate.waitForExistence(timeout: 1) { + candidate.tap() + return true } + return false + } + + private func tapKey(in app: XCUIApplication, _ label: String) { + let systemKeyboards = app.otherElements.matching(identifier: Self.systemKeyboardId) + let key = systemKeyboards.buttons[label].firstMatch + XCTAssertTrue(key.waitForExistence(timeout: 2), "KeyJawn key \(label)") + key.tap() } private func firstMatch(in app: XCUIApplication, labels: [String]) -> XCUIElement { for label in labels { - let button = app.buttons[label] + let button = app.buttons[label].firstMatch if button.exists { return button } - let text = app.staticTexts[label] + let text = app.staticTexts[label].firstMatch if text.exists { return text } - let cell = app.cells[label] + let cell = app.cells[label].firstMatch if cell.exists { return cell } } return app.descendants(matching: .any)[labels[0]] diff --git a/ios/UITests/LaunchFlowTests.swift b/ios/UITests/LaunchFlowTests.swift index 922356a..d4dd279 100644 --- a/ios/UITests/LaunchFlowTests.swift +++ b/ios/UITests/LaunchFlowTests.swift @@ -7,29 +7,54 @@ final class LaunchFlowTests: XCTestCase { override func setUpWithError() throws { continueAfterFailure = false + } + + @MainActor + private func launchApp() { app = XCUIApplication() app.launchArguments = ["-ui-testing"] app.launch() } - func testColdLaunchCanSkipOnboardingAndReachTheThreeTabs() { + @MainActor + func testColdLaunchCanSkipOnboardingAndReachTheHostsAndSettingsTabs() { + launchApp() let skip = app.buttons["onboarding.skip"] XCTAssertTrue(skip.waitForExistence(timeout: 5), "first launch must show Skip") + XCTAssertTrue( + app.descendants(matching: .any)["brand.prompt"].firstMatch.exists, + "onboarding must show the KeyJawn prompt mark" + ) skip.tap() activateTab("Hosts") XCTAssertTrue(app.navigationBars["Hosts"].waitForExistence(timeout: 5)) - activateTab("Preview") - XCTAssertTrue(app.navigationBars["Preview"].waitForExistence(timeout: 3)) + XCTAssertTrue(app.staticTexts["No hosts yet"].exists) + XCTAssertTrue( + app.staticTexts[ + "Connect to a remote SSH server. Commands run on that server. " + + "KeyJawn cannot browse files on your iPhone or iPad." + ].exists + ) + XCTAssertFalse(app.buttons["Preview"].exists) + XCTAssertTrue( + app.descendants(matching: .any)["brand.prompt"].firstMatch.exists, + "empty hosts must show the KeyJawn prompt mark" + ) + attachScreenshot(named: "03-remote-hosts") activateTab("Settings") XCTAssertTrue(app.navigationBars["Settings"].waitForExistence(timeout: 3)) XCTAssertTrue(app.buttons["settings.setupKeyboard"].exists) + XCTAssertTrue(app.descendants(matching: .any)["settings.brand"].firstMatch.exists) + attachScreenshot(named: "04-settings") activateTab("Hosts") XCTAssertTrue(app.navigationBars["Hosts"].waitForExistence(timeout: 3)) } + @MainActor func testSettingsReopensOnboardingAndSkipReturnsToSettings() { + launchApp() let skip = app.buttons["onboarding.skip"] if skip.waitForExistence(timeout: 5) { skip.tap() @@ -44,9 +69,12 @@ final class LaunchFlowTests: XCTestCase { XCTAssertTrue(app.navigationBars["Settings"].waitForExistence(timeout: 3)) } + @MainActor func testOnboardingContinueWalksEveryPageThenDone() { + launchApp() let skip = app.buttons["onboarding.skip"] if skip.waitForExistence(timeout: 5) { + attachScreenshot(named: "01-remote-boundary") skip.tap() } activateTab("Settings") @@ -55,7 +83,11 @@ final class LaunchFlowTests: XCTestCase { let continueButton = app.buttons["onboarding.continue"] XCTAssertTrue(continueButton.waitForExistence(timeout: 3)) continueButton.tap() - XCTAssertTrue(continueButton.waitForExistence(timeout: 2)) + XCTAssertTrue( + app.staticTexts["Turn on the keyboard"].waitForExistence(timeout: 2), + "the screenshot must wait for the Full Access page" + ) + attachScreenshot(named: "02-full-access") continueButton.tap() let done = app.buttons["onboarding.done"] XCTAssertTrue(done.waitForExistence(timeout: 2)) @@ -68,6 +100,7 @@ final class LaunchFlowTests: XCTestCase { /// outer item and a nested `_UIFloatingTabBarItemView`. Prefer the tab /// bar, then the SF Symbol identifier (one match), then the label with /// `firstMatch`. + @MainActor private func activateTab(_ name: String) { let tab = app.tabBars.buttons[name] if tab.waitForExistence(timeout: 2) { @@ -77,7 +110,6 @@ final class LaunchFlowTests: XCTestCase { let identifiers = [ "Hosts": "server.rack", - "Preview": "terminal", "Settings": "gearshape", ] if let id = identifiers[name] { @@ -92,4 +124,12 @@ final class LaunchFlowTests: XCTestCase { XCTAssertTrue(button.waitForExistence(timeout: 3), "tab \(name)") button.tap() } + + @MainActor + private func attachScreenshot(named name: String) { + let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } } diff --git a/ios/project.yml b/ios/project.yml index c7ae137..0c4fc77 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -22,6 +22,9 @@ packages: SwiftNIOSSH: url: https://github.com/Joannis/swift-nio-ssh from: "0.3.0" + SwiftNIOTransportServices: + url: https://github.com/apple/swift-nio-transport-services.git + from: "1.26.0" targets: @@ -40,10 +43,10 @@ targets: - package: Citadel - package: SwiftNIO product: NIOCore - - package: SwiftNIO - product: NIOPosix - package: SwiftNIOSSH product: NIOSSH + - package: SwiftNIOTransportServices + product: NIOTransportServices - target: KeyJawnKeyboard embed: true settings: @@ -63,11 +66,10 @@ targets: # settings, so bumping CURRENT_PROJECT_VERSION as the release checklist # instructed produced a build that still identified as build 1, which the # store rejects as a duplicate. - CURRENT_PROJECT_VERSION: 8 + CURRENT_PROJECT_VERSION: 9 MARKETING_VERSION: "1.0.0" TARGETED_DEVICE_FAMILY: "1,2" ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor configs: Release: CODE_SIGN_STYLE: Manual @@ -101,11 +103,6 @@ targets: - UIInterfaceOrientationLandscapeRight NSMicrophoneUsageDescription: "Voice input for terminal prompts" NSSpeechRecognitionUsageDescription: "Voice-to-text for typing prompts to CLI agents" - NSPhotoLibraryUsageDescription: "Upload images via SFTP to insert paths into prompts" - CFBundleURLTypes: - - CFBundleURLName: com.keyjawn - CFBundleURLSchemes: - - keyjawn KeyJawnKeyboard: type: app-extension @@ -123,6 +120,8 @@ targets: product: NIOCore - package: SwiftNIOSSH product: NIOSSH + - package: SwiftNIOTransportServices + product: NIOTransportServices settings: base: SWIFT_VERSION: "6.0" @@ -135,8 +134,9 @@ targets: # Must match the app target above. The extension had no version settings at # all and hardcoded them in its plist, so it could silently ship a version # that disagreed with the app's — an upload rejection at the last step. - CURRENT_PROJECT_VERSION: 8 + CURRENT_PROJECT_VERSION: 9 MARKETING_VERSION: "1.0.0" + TARGETED_DEVICE_FAMILY: "1,2" configs: Release: CODE_SIGN_STYLE: Manual @@ -152,7 +152,7 @@ targets: LegacyKeychainAccessGroup: "$(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)" NSExtension: NSExtensionAttributes: - IsASCIICapable: false + IsASCIICapable: true PrefersRightToLeft: false PrimaryLanguage: en-US RequestsOpenAccess: true diff --git a/ios/scripts/asc.py b/ios/scripts/asc.py index c585e24..6771704 100755 --- a/ios/scripts/asc.py +++ b/ios/scripts/asc.py @@ -99,10 +99,21 @@ def create_app(): def list_builds(bundle_id: str): s, _ = _session() + app_response = s.get(f"{BASE}/apps", params={ + "filter[bundleId]": bundle_id, + "limit": 1, + }) + app_response.raise_for_status() + apps = app_response.json().get("data", []) + if not apps: + print(f"No app found for {bundle_id}.") + return + + app_id = apps[0]["id"] r = s.get(f"{BASE}/builds", params={ - "filter[app.bundleId]": bundle_id, + "filter[app]": app_id, "sort": "-uploadedDate", - "limit": 10, + "limit": 200, }) r.raise_for_status() builds = r.json().get("data", []) diff --git a/ios/scripts/tests/test_asc.py b/ios/scripts/tests/test_asc.py new file mode 100644 index 0000000..be005a8 --- /dev/null +++ b/ios/scripts/tests/test_asc.py @@ -0,0 +1,83 @@ +import importlib.util +import io +from pathlib import Path +import unittest +from unittest.mock import patch + + +SCRIPT_PATH = Path(__file__).resolve().parents[1] / "asc.py" +SPEC = importlib.util.spec_from_file_location("keyjawn_asc", SCRIPT_PATH) +ASC = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(ASC) + + +class FakeResponse: + def __init__(self, payload): + self.payload = payload + + def raise_for_status(self): + return None + + def json(self): + return self.payload + + +class FakeSession: + def __init__(self): + self.calls = [] + + def get(self, url, params=None): + self.calls.append((url, params)) + if url == f"{ASC.BASE}/apps": + return FakeResponse({"data": [{"id": "6759345867"}]}) + if url == f"{ASC.BASE}/builds": + return FakeResponse({ + "data": [ + { + "attributes": { + "version": "2", + "processingState": "VALID", + "uploadedDate": "2026-02-20T12:00:00Z", + } + }, + { + "attributes": { + "version": "1", + "processingState": "VALID", + "uploadedDate": "2026-02-19T12:00:00Z", + } + }, + ] + }) + raise AssertionError(f"Unexpected URL: {url}") + + +class ListBuildsTests(unittest.TestCase): + def test_resolves_bundle_id_then_lists_the_apps_builds(self): + session = FakeSession() + with patch.object(ASC, "_session", return_value=(session, "token")): + with patch("sys.stdout", new_callable=io.StringIO) as output: + ASC.list_builds("com.keyjawn") + + self.assertEqual(session.calls, [ + ( + f"{ASC.BASE}/apps", + {"filter[bundleId]": "com.keyjawn", "limit": 1}, + ), + ( + f"{ASC.BASE}/builds", + { + "filter[app]": "6759345867", + "sort": "-uploadedDate", + "limit": 200, + }, + ), + ]) + lines = output.getvalue().splitlines() + self.assertIn("v2 VALID 2026-02-20", lines[0]) + self.assertIn("v1 VALID 2026-02-19", lines[1]) + + +if __name__ == "__main__": + unittest.main() diff --git a/ios/web-poc/README.md b/ios/web-poc/README.md new file mode 100644 index 0000000..4228576 --- /dev/null +++ b/ios/web-poc/README.md @@ -0,0 +1,8 @@ +# Retired web terminal prototype + +This February 2026 proof of concept is not the current KeyJawn iOS app. Do not +deploy it as a product surface or use it as App Store review evidence. + +The current iOS app connects directly to a user-configured remote SSH host. See +[`docs/ios-app-review.md`](../../docs/ios-app-review.md) for the current security +boundary and release checks. diff --git a/ios/web-poc/public/favicon.svg b/ios/web-poc/public/favicon.svg new file mode 100644 index 0000000..4690600 --- /dev/null +++ b/ios/web-poc/public/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ios/web-poc/public/index.html b/ios/web-poc/public/index.html index 0c7c911..80beeb0 100644 --- a/ios/web-poc/public/index.html +++ b/ios/web-poc/public/index.html @@ -6,8 +6,10 @@ - KeyJawn - + + + Retired KeyJawn web terminal prototype + @@ -16,6 +18,8 @@ +

Retired prototype. This is not the current KeyJawn iOS app.

+
diff --git a/ios/web-poc/public/style.css b/ios/web-poc/public/style.css index 428e239..85022dd 100644 --- a/ios/web-poc/public/style.css +++ b/ios/web-poc/public/style.css @@ -222,3 +222,12 @@ html, body { color: var(--sub-text); font-size: 0.85rem; } +/* This page is a retired internal prototype, not the current iOS product. */ +.prototype-notice { + margin: 0; + padding: 8px 12px; + background: #6cf2a8; + color: #111; + font: 600 12px/1.4 -apple-system, BlinkMacSystemFont, sans-serif; + text-align: center; +} diff --git a/store/.env.example b/store/.env.example index bffa932..1a13848 100644 --- a/store/.env.example +++ b/store/.env.example @@ -1,6 +1,7 @@ STRIPE_API_KEY=sk_live_... STRIPE_WEBHOOK_SECRET=whsec_... ADMIN_TOKEN=changeme +UNSUBSCRIBE_SECRET=generate-a-random-secret GMAIL_APP_PASSWORD=... TELEGRAM_BOT_TOKEN=... TELEGRAM_CHAT_ID=... diff --git a/store/auth.py b/store/auth.py new file mode 100644 index 0000000..bca62c1 --- /dev/null +++ b/store/auth.py @@ -0,0 +1,21 @@ +import hmac +import os +from collections.abc import Mapping + + +def load_admin_token(environment: Mapping[str, str] = os.environ) -> str: + """Load the required admin token or stop service startup.""" + token = environment.get("ADMIN_TOKEN", "") + if not token: + raise RuntimeError("ADMIN_TOKEN env var must be set before starting the store service") + return token + + +ADMIN_TOKEN = load_admin_token() + + +def admin_token_matches(candidate: str) -> bool: + """Compare a submitted admin token without early-exit timing differences.""" + return bool(candidate) and hmac.compare_digest( + candidate.encode("utf-8"), ADMIN_TOKEN.encode("utf-8") + ) diff --git a/store/db.py b/store/db.py index 82c57c6..d91f3cc 100644 --- a/store/db.py +++ b/store/db.py @@ -3,6 +3,12 @@ DB_PATH = os.path.join(os.path.dirname(__file__), "keyjawn-store.db") + +def normalize_email(email: str) -> str: + """Use one email identity for storage, tokens, and account lookups.""" + return str(email).strip().lower() + + def get_db(): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row @@ -15,7 +21,7 @@ def init_db(): conn.executescript(""" CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY, - email TEXT UNIQUE NOT NULL, + email TEXT COLLATE NOCASE UNIQUE NOT NULL, stripe_customer_id TEXT, stripe_payment_intent TEXT, amount_cents INTEGER NOT NULL DEFAULT 400, @@ -57,6 +63,17 @@ def init_db(): released_at TEXT NOT NULL DEFAULT (datetime('now')) ); """) + try: + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS users_email_nocase " + "ON users(email COLLATE NOCASE)" + ) + except sqlite3.IntegrityError as error: + conn.rollback() + conn.close() + raise RuntimeError( + "users contains case-variant email collisions; resolve them before startup" + ) from error # Add changelog column if missing (existing DBs) try: conn.execute("ALTER TABLE releases ADD COLUMN changelog TEXT") @@ -67,4 +84,5 @@ def init_db(): conn.execute("ALTER TABLE users ADD COLUMN unsubscribed INTEGER NOT NULL DEFAULT 0") except sqlite3.OperationalError: pass # column already exists + conn.commit() conn.close() diff --git a/store/email_sender.py b/store/email_sender.py index aac1d05..5e0d393 100644 --- a/store/email_sender.py +++ b/store/email_sender.py @@ -67,7 +67,7 @@ def _canspam_footer(email: str) -> str: """ -def _send_email(to: str, subject: str, html: str): +def _send_email(to: str, subject: str, html: str) -> bool: msg = MIMEMultipart("alternative") msg["From"] = f"{FROM_NAME} <{FROM_EMAIL}>" msg["To"] = to @@ -80,8 +80,10 @@ def _send_email(to: str, subject: str, html: str): smtp.login(FROM_EMAIL, GMAIL_APP_PASSWORD) smtp.sendmail(FROM_EMAIL, [to, FROM_EMAIL], msg.as_string()) log.info(f"Email sent to {to}") + return True except Exception as e: log.error(f"Failed to send email to {to}: {e}") + return False def _download_email_html(version: str, to_email: str) -> str: @@ -241,7 +243,7 @@ def _update_email_html(version: str, changelog: str = "", to_email: str = "") -> def send_download_email(to_email: str): version = _get_latest_version() - _send_email( + return _send_email( to_email, f"KeyJawn v{version} -- your download link", _download_email_html(version, to_email), @@ -250,7 +252,7 @@ def send_download_email(to_email: str): def send_update_email(to_email: str, version: str, changelog: str = ""): """Send an update notification to an existing purchaser.""" - _send_email( + return _send_email( to_email, f"KeyJawn v{version} -- what's new", _update_email_html(version, changelog, to_email), @@ -258,7 +260,7 @@ def send_update_email(to_email: str, version: str, changelog: str = ""): def send_ticket_confirmation(to_email: str, subject: str, ticket_id: int): - _send_email( + return _send_email( to_email, f"Re: {subject} [#{ticket_id}]", _ticket_email_html(ticket_id, to_email), diff --git a/store/routes/admin.py b/store/routes/admin.py index 4dfc6e5..80c442a 100644 --- a/store/routes/admin.py +++ b/store/routes/admin.py @@ -2,17 +2,15 @@ from fastapi import APIRouter, Request, HTTPException from fastapi.responses import RedirectResponse from fastapi.templating import Jinja2Templates +from auth import admin_token_matches from db import get_db router = APIRouter(prefix="/admin") templates = Jinja2Templates(directory=os.path.join(os.path.dirname(os.path.dirname(__file__)), "templates")) -def get_admin_token(): - return os.environ.get("ADMIN_TOKEN", "") - def check_auth(request: Request): token = request.cookies.get("admin_token") - if token != get_admin_token(): + if not admin_token_matches(token or ""): raise HTTPException(302, headers={"Location": "/admin/login"}) @router.get("/login") @@ -23,7 +21,7 @@ async def login_page(request: Request): async def login(request: Request): form = await request.form() token = form.get("token", "") - if token != get_admin_token(): + if not admin_token_matches(str(token)): return templates.TemplateResponse(request, "admin/login.html", {"error": "Invalid token"}, status_code=401) response = RedirectResponse("/admin", status_code=303) response.set_cookie("admin_token", token, httponly=True, samesite="strict", secure=True) diff --git a/store/routes/download.py b/store/routes/download.py index 8c9a02b..f89f8aa 100644 --- a/store/routes/download.py +++ b/store/routes/download.py @@ -1,45 +1,96 @@ import logging -from datetime import datetime, timezone -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, BackgroundTasks from pydantic import BaseModel, EmailStr -from db import get_db +from db import get_db, normalize_email from email_sender import send_download_email log = logging.getLogger("keyjawn-store") router = APIRouter() _GENERIC_OK = {"status": "ok", "message": "If that email is registered, you'll receive a download link shortly."} +_DAILY_ATTEMPT_LIMIT = 5 class DownloadRequest(BaseModel): email: EmailStr +def deliver_download_email(download_id: int, user_id: int, recipient: str) -> None: + """Deliver one download email and retain the attempt for rate limiting.""" + try: + delivered = send_download_email(recipient) + except Exception: + log.exception("download email raised for user %s", user_id) + delivered = False + + conn = get_db() + try: + if delivered: + conn.execute( + """ + UPDATE users + SET download_count = download_count + 1, + last_download_at = datetime('now') + WHERE id = ? + """, + (user_id,), + ) + conn.commit() + finally: + conn.close() + + @router.post("/api/download", status_code=202) -async def download(req: DownloadRequest): +async def download(req: DownloadRequest, background_tasks: BackgroundTasks): + email = normalize_email(req.email) conn = get_db() - user = conn.execute("SELECT * FROM users WHERE email = ?", (req.email,)).fetchone() + try: + user = conn.execute( + "SELECT * FROM users WHERE email = ? COLLATE NOCASE", (email,) + ).fetchone() - if not user: - conn.close() - log.info("download requested for unknown email (not disclosed to caller)") - return _GENERIC_OK - - # Rate limit: 5 download emails per day - today = datetime.now(timezone.utc).strftime("%Y-%m-%d") - count = conn.execute(""" - SELECT COUNT(*) FROM downloads - WHERE user_id = ? AND downloaded_at >= ? - """, (user["id"], today)).fetchone()[0] - if count >= 5: - conn.close() - raise HTTPException(429, "Download limit reached. Try again tomorrow.") + if not user: + log.info("download requested for unknown email (not disclosed to caller)") + return _GENERIC_OK + + conn.execute("BEGIN IMMEDIATE") + daily_attempts = conn.execute(""" + SELECT COUNT(*) FROM downloads + WHERE user_id = ? + AND downloaded_at >= datetime('now', 'start of day') + """, (user["id"],)).fetchone()[0] + if daily_attempts >= _DAILY_ATTEMPT_LIMIT: + conn.rollback() + log.info("download email suppressed by daily limit for user %s", user["id"]) + return _GENERIC_OK - # Log download and send link via email - conn.execute("INSERT INTO downloads (user_id, version) VALUES (?, (SELECT version FROM releases ORDER BY released_at DESC LIMIT 1))", (user["id"],)) - conn.execute("UPDATE users SET download_count = download_count + 1, last_download_at = datetime('now') WHERE id = ?", (user["id"],)) - conn.commit() - conn.close() + recent_request = conn.execute(""" + SELECT 1 FROM downloads + WHERE user_id = ? + AND downloaded_at >= datetime('now', '-10 minutes') + LIMIT 1 + """, (user["id"],)).fetchone() + if recent_request: + conn.rollback() + log.info("download email suppressed by cooldown for user %s", user["id"]) + return _GENERIC_OK + + release = conn.execute( + "SELECT version FROM releases ORDER BY released_at DESC LIMIT 1" + ).fetchone() + version = release["version"] if release else "latest" + result = conn.execute( + "INSERT INTO downloads (user_id, version) VALUES (?, ?)", + (user["id"], version), + ) + conn.commit() + download_id = result.lastrowid + user_id = user["id"] + recipient = user["email"] + finally: + conn.close() - send_download_email(req.email) + background_tasks.add_task( + deliver_download_email, download_id, user_id, recipient + ) return _GENERIC_OK diff --git a/store/routes/releases.py b/store/routes/releases.py index ca9fba2..df62b30 100644 --- a/store/routes/releases.py +++ b/store/routes/releases.py @@ -1,8 +1,8 @@ -import os import logging from fastapi import APIRouter, HTTPException, Header from pydantic import BaseModel from typing import Optional +from auth import admin_token_matches from db import get_db from email_sender import send_update_email from telegram import send_telegram_alert @@ -12,11 +12,10 @@ def require_admin(authorization: Optional[str] = Header(None)): - admin_token = os.environ.get("ADMIN_TOKEN", "") if not authorization or not authorization.startswith("Bearer "): raise HTTPException(401, "Missing or invalid token") token = authorization.split(" ", 1)[1] - if token != admin_token: + if not admin_token_matches(token): raise HTTPException(401, "Invalid token") @@ -62,11 +61,15 @@ async def notify_purchasers(version: str, authorization: Optional[str] = Header( failed = 0 for user in users: try: - send_update_email(user["email"], version, changelog) - sent += 1 + delivered = send_update_email(user["email"], version, changelog) except Exception as e: log.error(f"Failed to notify {user['email']}: {e}") failed += 1 + else: + if delivered: + sent += 1 + else: + failed += 1 send_telegram_alert(f"KeyJawn v{version} update emails: {sent} sent, {failed} failed") return {"version": version, "sent": sent, "failed": failed} diff --git a/store/routes/support.py b/store/routes/support.py index b375d72..77f35ea 100644 --- a/store/routes/support.py +++ b/store/routes/support.py @@ -1,14 +1,19 @@ import logging -from fastapi import APIRouter +from fastapi import APIRouter, BackgroundTasks from pydantic import BaseModel, EmailStr from typing import Optional -from db import get_db +from db import get_db, normalize_email from telegram import send_telegram_alert from email_sender import send_ticket_confirmation log = logging.getLogger("keyjawn-store") router = APIRouter() +_GENERIC_OK = { + "status": "ok", + "message": "If that email is registered, we'll follow up shortly.", +} + class SupportRequest(BaseModel): email: EmailStr @@ -19,14 +24,17 @@ class SupportRequest(BaseModel): app_version: Optional[str] = None @router.post("/api/support", status_code=202) -async def create_ticket(req: SupportRequest): +async def create_ticket(req: SupportRequest, background_tasks: BackgroundTasks): + email = normalize_email(req.email) conn = get_db() - user = conn.execute("SELECT * FROM users WHERE email = ?", (req.email,)).fetchone() + user = conn.execute( + "SELECT * FROM users WHERE email = ? COLLATE NOCASE", (email,) + ).fetchone() if not user: conn.close() log.info("support ticket from unknown email (not disclosed to caller)") - return {"status": "ok", "message": "If that email is registered, we'll follow up shortly."} + return _GENERIC_OK cursor = conn.execute(""" INSERT INTO tickets (user_id, subject, body, device_model, android_version, app_version) @@ -36,7 +44,12 @@ async def create_ticket(req: SupportRequest): conn.commit() conn.close() - send_telegram_alert(f"New keyjawn support ticket #{ticket_id} from {req.email}: {req.subject}") - send_ticket_confirmation(req.email, req.subject, ticket_id) + background_tasks.add_task( + send_telegram_alert, + f"New keyjawn support ticket #{ticket_id} from {user['email']}: {req.subject}", + ) + background_tasks.add_task( + send_ticket_confirmation, user["email"], req.subject, ticket_id + ) - return {"ticket_id": ticket_id, "status": "open"} + return _GENERIC_OK diff --git a/store/routes/unsubscribe.py b/store/routes/unsubscribe.py index 28cc78e..b40fb7a 100644 --- a/store/routes/unsubscribe.py +++ b/store/routes/unsubscribe.py @@ -2,11 +2,13 @@ import hmac import logging import os +from html import escape +from urllib.parse import urlencode -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, HTTPException from fastapi.responses import HTMLResponse -from db import get_db +from db import get_db, normalize_email log = logging.getLogger("keyjawn-store") router = APIRouter() @@ -19,35 +21,53 @@ def make_unsubscribe_token(email: str) -> str: """HMAC token so users can only unsubscribe themselves.""" return hmac.new( - UNSUBSCRIBE_SECRET.encode(), email.lower().encode(), hashlib.sha256 + UNSUBSCRIBE_SECRET.encode(), normalize_email(email).encode(), hashlib.sha256 ).hexdigest()[:16] def make_unsubscribe_url(email: str) -> str: - token = make_unsubscribe_token(email) + normalized_email = normalize_email(email) + query = urlencode( + { + "email": normalized_email, + "token": make_unsubscribe_token(normalized_email), + } + ) base = os.environ.get("BASE_URL", "https://keyjawn-store.amditis.tech") - return f"{base}/unsubscribe?email={email}&token={token}" + return f"{base}/unsubscribe?{query}" -@router.get("/unsubscribe", response_class=HTMLResponse) -async def unsubscribe(email: str = "", token: str = ""): +def validated_email(email: str, token: str) -> str: + """Validate a signed unsubscribe request and return its email identity.""" if not email or not token: raise HTTPException(400, "Missing email or token") - expected = make_unsubscribe_token(email) - if not hmac.compare_digest(token, expected): + normalized_email = normalize_email(email) + expected = make_unsubscribe_token(normalized_email) + if not hmac.compare_digest(token.encode("utf-8"), expected.encode("ascii")): raise HTTPException(400, "Invalid unsubscribe link") + return normalized_email - conn = get_db() - conn.execute("UPDATE users SET unsubscribed = 1 WHERE email = ?", (email,)) - conn.commit() - conn.close() - log.info("unsubscribed: %s", email) - - return """ - -Unsubscribed +def render_unsubscribe_page(title: str, description: str, content: str) -> str: + """Render one metadata-complete unsubscribe page.""" + safe_title = escape(title) + safe_description = escape(description) + return f""" + + + + + + + + + + + + {safe_title} + +
@@ -56,16 +76,65 @@ async def unsubscribe(email: str = "", token: str = ""): KeyJawn
-

- You've been unsubscribed from KeyJawn update emails. -

-

- You can still download your purchased version anytime at - keyjawn.amditis.tech. -

+ {content}
""" + + +@router.get("/unsubscribe", response_class=HTMLResponse) +async def confirm_unsubscribe(email: str = "", token: str = ""): + normalized_email = validated_email(email, token) + query = urlencode( + { + "email": normalized_email, + "token": make_unsubscribe_token(normalized_email), + } + ) + action = escape(f"/unsubscribe?{query}", quote=True) + content = f""" +

+ Confirm that you want to stop KeyJawn update emails. +

+
+ +
""" + return render_unsubscribe_page( + "Confirm unsubscribe", + "Confirm that you want to stop KeyJawn update emails.", + content, + ) + + +@router.post("/unsubscribe", response_class=HTMLResponse) +async def unsubscribe(email: str = "", token: str = ""): + normalized_email = validated_email(email, token) + + conn = get_db() + conn.execute( + "UPDATE users SET unsubscribed = 1 WHERE email = ? COLLATE NOCASE", + (normalized_email,), + ) + conn.commit() + conn.close() + + log.info("unsubscribed: %s", normalized_email) + + content = """ +

+ You've been unsubscribed from KeyJawn update emails. +

+

+ You can still download your purchased version anytime at + keyjawn.amditis.tech. +

""" + return render_unsubscribe_page( + "Unsubscribed from KeyJawn", + "KeyJawn email unsubscribe confirmation.", + content, + ) diff --git a/store/routes/webhook.py b/store/routes/webhook.py index 8a76657..009b71a 100644 --- a/store/routes/webhook.py +++ b/store/routes/webhook.py @@ -2,7 +2,7 @@ import logging import stripe from fastapi import APIRouter, Request, HTTPException -from db import get_db +from db import get_db, normalize_email from email_sender import send_download_email log = logging.getLogger("keyjawn-store") @@ -25,22 +25,28 @@ async def stripe_webhook(request: Request): if event["type"] == "checkout.session.completed": session = event["data"]["object"] - email = session["customer_details"]["email"] + email = normalize_email(session["customer_details"]["email"]) customer_id = session.get("customer") payment_intent = session.get("payment_intent") amount = session.get("amount_total", 400) conn = get_db() - result = conn.execute(""" - INSERT INTO users (email, stripe_customer_id, stripe_payment_intent, amount_cents) - VALUES (?, ?, ?, ?) - ON CONFLICT(email) DO NOTHING - """, [email, customer_id, payment_intent, amount]) + existing_user = conn.execute( + "SELECT id FROM users WHERE email = ? COLLATE NOCASE", (email,) + ).fetchone() + is_new_user = False + if not existing_user: + result = conn.execute(""" + INSERT INTO users (email, stripe_customer_id, stripe_payment_intent, amount_cents) + VALUES (?, ?, ?, ?) + ON CONFLICT(email) DO NOTHING + """, [email, customer_id, payment_intent, amount]) + is_new_user = result.rowcount > 0 conn.commit() conn.close() # Only send the welcome email if this is a new user (not a duplicate event) - if result.rowcount > 0: + if is_new_user: send_download_email(email) else: log.info("duplicate stripe event for %s — skipping download email", email) diff --git a/store/start.sh b/store/start.sh index b2eef64..035fa3b 100755 --- a/store/start.sh +++ b/store/start.sh @@ -1,18 +1,24 @@ #!/bin/bash +set -euo pipefail + # Pull secrets from pass and start the store service. # Used by systemd instead of EnvironmentFile. PASS="/usr/bin/pass" -export STRIPE_API_KEY="$($PASS show claude/services/keyjawn-stripe-api-key)" -export STRIPE_WEBHOOK_SECRET="$($PASS show claude/services/keyjawn-stripe-webhook-secret)" -export ADMIN_TOKEN="$($PASS show claude/services/keyjawn-admin-token)" -export GMAIL_APP_PASSWORD="$($PASS show claude/services/gmail-keyjawn | tr -d ' ')" -export TELEGRAM_BOT_TOKEN="$($PASS show claude/tokens/telegram-bot)" +STRIPE_API_KEY="$($PASS show claude/services/keyjawn-stripe-api-key)" +STRIPE_WEBHOOK_SECRET="$($PASS show claude/services/keyjawn-stripe-webhook-secret)" +ADMIN_TOKEN="$($PASS show claude/services/keyjawn-admin-token)" +UNSUBSCRIBE_SECRET="$($PASS show claude/services/keyjawn-unsubscribe-secret)" +GMAIL_APP_PASSWORD="$($PASS show claude/services/gmail-keyjawn | tr -d ' ')" +TELEGRAM_BOT_TOKEN="$($PASS show claude/tokens/telegram-bot)" export TELEGRAM_CHAT_ID="743339387" -export R2_ACCESS_KEY_ID="$($PASS show claude/api/cloudflare-r2-access-key-id)" -export R2_SECRET_ACCESS_KEY="$($PASS show claude/api/cloudflare-r2-secret-access-key)" +R2_ACCESS_KEY_ID="$($PASS show claude/api/cloudflare-r2-access-key-id)" +R2_SECRET_ACCESS_KEY="$($PASS show claude/api/cloudflare-r2-secret-access-key)" export R2_ACCOUNT_ID="3d4b1d36109e30866bb7516502224b2c" +export STRIPE_API_KEY STRIPE_WEBHOOK_SECRET ADMIN_TOKEN UNSUBSCRIBE_SECRET +export GMAIL_APP_PASSWORD TELEGRAM_BOT_TOKEN R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY + exec /home/jamditis/projects/keyjawn/store/venv/bin/gunicorn \ -b 127.0.0.1:5060 -w 2 -k uvicorn.workers.UvicornWorker app:app diff --git a/store/static/favicon.svg b/store/static/favicon.svg new file mode 100644 index 0000000..d13d9b6 --- /dev/null +++ b/store/static/favicon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + KJ + diff --git a/store/templates/admin/base.html b/store/templates/admin/base.html index 51d4a60..f70f0fe 100644 --- a/store/templates/admin/base.html +++ b/store/templates/admin/base.html @@ -3,8 +3,9 @@ + {% block title %}Admin{% endblock %} - keyjawn-store - + diff --git a/store/templates/admin/login.html b/store/templates/admin/login.html index e517002..11a0591 100644 --- a/store/templates/admin/login.html +++ b/store/templates/admin/login.html @@ -3,8 +3,9 @@ + Login - keyjawn-store - + diff --git a/store/tests/conftest.py b/store/tests/conftest.py new file mode 100644 index 0000000..2dfd3fd --- /dev/null +++ b/store/tests/conftest.py @@ -0,0 +1,30 @@ +import os +import sys +import tempfile +from pathlib import Path + +import pytest + + +os.environ.setdefault("ADMIN_TOKEN", "testtoken") +os.environ.setdefault("STRIPE_WEBHOOK_SECRET", "test-webhook-secret") +os.environ.setdefault("UNSUBSCRIBE_SECRET", "test-unsubscribe-secret") + +STORE_DIRECTORY = Path(__file__).parents[1] +sys.path.insert(0, str(STORE_DIRECTORY)) + +import db # noqa: E402 + + +# Test modules import the FastAPI application during collection. Give that +# import a disposable database before any per-test fixture can run. +_SESSION_DATABASE_DIRECTORY = tempfile.TemporaryDirectory(prefix="keyjawn-store-tests-") +db.DB_PATH = str(Path(_SESSION_DATABASE_DIRECTORY.name) / "collection.db") + + +@pytest.fixture(autouse=True) +def isolated_database(tmp_path, monkeypatch): + database_path = tmp_path / "keyjawn-store.db" + monkeypatch.setattr(db, "DB_PATH", str(database_path)) + db.init_db() + return database_path diff --git a/store/tests/test_admin.py b/store/tests/test_admin.py index 28444be..d914e33 100644 --- a/store/tests/test_admin.py +++ b/store/tests/test_admin.py @@ -1,26 +1,73 @@ import sys, os sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from pathlib import Path + +import pytest from fastapi.testclient import TestClient + +def test_store_startup_contract_includes_required_secrets(): + store_directory = Path(__file__).parents[1] + start_script = (store_directory / "start.sh").read_text() + env_example = (store_directory / ".env.example").read_text() + + assert "set -euo pipefail" in start_script + assert "export STRIPE_API_KEY STRIPE_WEBHOOK_SECRET ADMIN_TOKEN UNSUBSCRIBE_SECRET" in start_script + assert 'export UNSUBSCRIBE_SECRET="$(' not in start_script + assert "UNSUBSCRIBE_SECRET=" in env_example + + +def test_admin_token_validation_fails_closed(monkeypatch): + from auth import load_admin_token + + with pytest.raises(RuntimeError, match="ADMIN_TOKEN"): + load_admin_token({}) + + +def test_admin_token_rejects_non_ascii_input(): + from auth import admin_token_matches + + assert admin_token_matches("é") is False + def test_admin_requires_auth(): from app import app client = TestClient(app) resp = client.get("/admin", follow_redirects=False) - assert resp.status_code == 401 + assert resp.status_code == 302 + assert resp.headers["location"] == "/admin/login" def test_admin_login_with_token(): os.environ["ADMIN_TOKEN"] = "testtoken" from db import init_db init_db() from app import app - client = TestClient(app) - # First request sets the cookie - resp = client.get("/admin?token=testtoken", follow_redirects=False) - assert resp.status_code == 302 + client = TestClient(app, base_url="https://testserver") + resp = client.post( + "/admin/login", + data={"token": "testtoken"}, + follow_redirects=False, + ) + assert resp.status_code == 303 assert resp.headers["location"] == "/admin" - # Follow redirect — client auto-persists cookies from the redirect response - client.cookies.update(resp.cookies) resp2 = client.get("/admin") assert resp2.status_code == 200 assert "Dashboard" in resp2.text + assert 'href="/static/favicon.svg"' in resp2.text + assert "https://keyjawn.amditis.tech/favicon.svg" not in resp2.text + + +def test_admin_login_page_is_not_indexed(): + from app import app + + client = TestClient(app) + response = client.get("/admin/login") + + assert response.status_code == 200 + assert '' in response.text + assert 'href="/static/favicon.svg"' in response.text + assert "https://keyjawn.amditis.tech/favicon.svg" not in response.text + + favicon = client.get("/static/favicon.svg") + assert favicon.status_code == 200 + assert favicon.headers["content-type"].startswith("image/svg+xml") diff --git a/store/tests/test_db.py b/store/tests/test_db.py index 5989945..189615a 100644 --- a/store/tests/test_db.py +++ b/store/tests/test_db.py @@ -1,22 +1,68 @@ -import sys, os -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) - -import tempfile -from unittest.mock import patch - -def test_init_db_creates_tables(): - with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: - tmp_path = f.name - try: - with patch("db.DB_PATH", tmp_path): - from db import init_db, get_db - init_db() - conn = get_db() - tables = [r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()] - conn.close() - assert "users" in tables - assert "downloads" in tables - assert "tickets" in tables - assert "releases" in tables - finally: - os.unlink(tmp_path) +from pathlib import Path +import sqlite3 + +import db +import pytest + + +def test_sqlite_sidecars_are_ignored(): + repository_root = Path(__file__).parents[2] + ignore_rules = (repository_root / ".gitignore").read_text().splitlines() + + assert "store/keyjawn-store.db-*" in ignore_rules + + +def test_suite_does_not_use_repository_database(): + repository_database = Path(__file__).parents[1] / "keyjawn-store.db" + + assert Path(db.DB_PATH).resolve() != repository_database.resolve() + + +def test_init_db_creates_tables(tmp_path, monkeypatch): + database_path = tmp_path / "schema.db" + monkeypatch.setattr(db, "DB_PATH", str(database_path)) + db.init_db() + + conn = db.get_db() + tables = [ + row[0] + for row in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall() + ] + conn.close() + + assert {"users", "downloads", "tickets", "releases"}.issubset(tables) + + +def test_users_email_is_unique_without_case_sensitivity(tmp_path, monkeypatch): + database_path = tmp_path / "schema.db" + monkeypatch.setattr(db, "DB_PATH", str(database_path)) + db.init_db() + + conn = db.get_db() + conn.execute("INSERT INTO users (email) VALUES (?)", ("Buyer@Example.com",)) + with pytest.raises(sqlite3.IntegrityError): + conn.execute("INSERT INTO users (email) VALUES (?)", ("buyer@example.com",)) + conn.close() + + +def test_init_db_fails_safely_on_legacy_case_collisions(tmp_path, monkeypatch): + database_path = tmp_path / "legacy.db" + conn = sqlite3.connect(database_path) + conn.execute( + "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE NOT NULL)" + ) + conn.execute("INSERT INTO users (email) VALUES (?)", ("Buyer@Example.com",)) + conn.execute("INSERT INTO users (email) VALUES (?)", ("buyer@example.com",)) + conn.commit() + conn.close() + monkeypatch.setattr(db, "DB_PATH", str(database_path)) + + with pytest.raises(RuntimeError, match="case-variant"): + db.init_db() + + conn = sqlite3.connect(database_path) + count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] + conn.close() + assert count == 2 diff --git a/store/tests/test_download.py b/store/tests/test_download.py index 4e2ed9f..0d84e56 100644 --- a/store/tests/test_download.py +++ b/store/tests/test_download.py @@ -1,7 +1,9 @@ import sys, os sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -import pytest +import asyncio + +from fastapi import BackgroundTasks from fastapi.testclient import TestClient from unittest.mock import patch @@ -26,21 +28,159 @@ def setup_test_user(): conn.close() -def test_download_rejects_unknown_email(): +def test_download_does_not_disclose_unknown_email(): setup_test_user() from app import app client = TestClient(app) - resp = client.post("/api/download", json={"email": "nobody@example.com"}) - assert resp.status_code == 404 + with patch("routes.download.send_download_email") as send_download_email: + resp = client.post("/api/download", json={"email": "nobody@example.com"}) + + assert resp.status_code == 202 + assert resp.json()["status"] == "ok" + send_download_email.assert_not_called() -def test_download_returns_url_for_paid_user(): +def test_download_emails_link_to_paid_user(): setup_test_user() from app import app client = TestClient(app) - with patch("routes.download.generate_signed_url", return_value="https://r2.example.com/keyjawn.apk?sig=abc"): + with patch("routes.download.send_download_email") as send_download_email: resp = client.post("/api/download", json={"email": "buyer@example.com"}) - assert resp.status_code == 200 - data = resp.json() - assert "url" in data - assert "version" in data + + assert resp.status_code == 202 + assert resp.json()["status"] == "ok" + send_download_email.assert_called_once_with("buyer@example.com") + + +def test_download_matches_legacy_email_without_case_sensitivity(): + setup_test_user() + from app import app + from db import get_db + + conn = get_db() + conn.execute( + "UPDATE users SET email = ? WHERE email = ?", + ("Buyer@Example.com", "buyer@example.com"), + ) + conn.commit() + conn.close() + + client = TestClient(app) + with patch("routes.download.send_download_email") as send_download_email: + resp = client.post("/api/download", json={"email": "buyer@example.com"}) + + assert resp.status_code == 202 + send_download_email.assert_called_once_with("Buyer@Example.com") + + +def test_download_cooldown_is_uniform_and_does_not_send_twice(): + setup_test_user() + from app import app + + client = TestClient(app) + with patch("routes.download.send_download_email") as send_download_email: + first = client.post("/api/download", json={"email": "buyer@example.com"}) + second = client.post("/api/download", json={"email": "buyer@example.com"}) + unknown = client.post("/api/download", json={"email": "nobody@example.com"}) + + assert first.status_code == 202 + assert second.status_code == 202 + assert first.json() == second.json() == unknown.json() + send_download_email.assert_called_once_with("buyer@example.com") + + +def test_download_daily_limit_is_uniform_and_does_not_schedule_an_email(): + setup_test_user() + from app import app + from db import get_db + + conn = get_db() + user_id = conn.execute( + "SELECT id FROM users WHERE email = ?", ("buyer@example.com",) + ).fetchone()[0] + for minute in range(5): + conn.execute( + """ + INSERT INTO downloads (user_id, version, downloaded_at) + VALUES (?, ?, datetime('now', 'start of day', ?)) + """, + (user_id, "0.2.0", f"+{minute} minutes"), + ) + conn.commit() + conn.close() + + client = TestClient(app) + with patch("routes.download.send_download_email") as sender: + limited = client.post("/api/download", json={"email": "buyer@example.com"}) + unknown = client.post("/api/download", json={"email": "nobody@example.com"}) + + assert limited.status_code == 202 + assert limited.json() == unknown.json() + sender.assert_not_called() + + conn = get_db() + attempt_count = conn.execute( + "SELECT COUNT(*) FROM downloads WHERE user_id = ?", (user_id,) + ).fetchone()[0] + conn.close() + assert attempt_count == 5 + + +def test_download_uses_latest_fallback_when_no_release_exists(): + setup_test_user() + from app import app + from db import get_db + + conn = get_db() + conn.execute("DELETE FROM releases") + conn.commit() + conn.close() + + client = TestClient(app) + with patch("routes.download.send_download_email") as send_download_email: + response = client.post("/api/download", json={"email": "buyer@example.com"}) + + assert response.status_code == 202 + send_download_email.assert_called_once_with("buyer@example.com") + + conn = get_db() + download = conn.execute("SELECT version FROM downloads").fetchone() + conn.close() + assert download["version"] == "latest" + + +def test_download_schedules_delivery_after_response(): + setup_test_user() + from routes.download import DownloadRequest, download + + background_tasks = BackgroundTasks() + request = DownloadRequest(email="buyer@example.com") + with patch("routes.download.send_download_email") as send_download_email: + response = asyncio.run(download(request, background_tasks)) + + assert response["status"] == "ok" + assert len(background_tasks.tasks) == 1 + send_download_email.assert_not_called() + + +def test_failed_download_delivery_keeps_cooldown_and_attempt_record(): + setup_test_user() + from app import app + from db import get_db + + client = TestClient(app) + with patch("routes.download.send_download_email", return_value=False) as sender: + first = client.post("/api/download", json={"email": "buyer@example.com"}) + second = client.post("/api/download", json={"email": "buyer@example.com"}) + + assert first.status_code == second.status_code == 202 + assert sender.call_count == 1 + + conn = get_db() + download_count = conn.execute("SELECT COUNT(*) FROM downloads").fetchone()[0] + user_count = conn.execute( + "SELECT download_count FROM users WHERE email = ?", ("buyer@example.com",) + ).fetchone()[0] + conn.close() + assert download_count == 1 + assert user_count == 0 diff --git a/store/tests/test_releases.py b/store/tests/test_releases.py index 789d873..b4eb177 100644 --- a/store/tests/test_releases.py +++ b/store/tests/test_releases.py @@ -2,6 +2,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from fastapi.testclient import TestClient +from unittest.mock import patch def test_register_release_requires_auth(): @@ -24,3 +25,33 @@ def test_register_release_with_valid_token(): ) assert resp.status_code == 200 assert resp.json()["version"] == "0.2.0" + + +def test_notify_counts_actual_email_results(): + os.environ["ADMIN_TOKEN"] = "testtoken" + from app import app + from db import get_db + + conn = get_db() + conn.execute( + "INSERT INTO releases (version, r2_key) VALUES (?, ?)", + ("0.3.0", "keyjawn/releases/v0.3.0/app.apk"), + ) + conn.execute("INSERT INTO users (email) VALUES (?)", ("one@example.com",)) + conn.execute("INSERT INTO users (email) VALUES (?)", ("two@example.com",)) + conn.commit() + conn.close() + + client = TestClient(app) + with patch("routes.releases.send_update_email", side_effect=[True, False]): + with patch("routes.releases.send_telegram_alert") as telegram: + response = client.post( + "/api/releases/0.3.0/notify", + headers={"Authorization": "Bearer testtoken"}, + ) + + assert response.status_code == 200 + assert response.json() == {"version": "0.3.0", "sent": 1, "failed": 1} + telegram.assert_called_once_with( + "KeyJawn v0.3.0 update emails: 1 sent, 1 failed" + ) diff --git a/store/tests/test_support.py b/store/tests/test_support.py index 045870c..1a61811 100644 --- a/store/tests/test_support.py +++ b/store/tests/test_support.py @@ -1,10 +1,19 @@ import sys, os sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +import asyncio + +from fastapi import BackgroundTasks from fastapi.testclient import TestClient from unittest.mock import patch +GENERIC_SUPPORT_RESPONSE = { + "status": "ok", + "message": "If that email is registered, we'll follow up shortly.", +} + + def setup_test_user(): from db import get_db, init_db init_db() @@ -20,16 +29,22 @@ def setup_test_user(): conn.close() -def test_support_rejects_non_purchaser(): +def test_support_does_not_disclose_non_purchaser(): setup_test_user() from app import app client = TestClient(app) - resp = client.post("/api/support", json={ - "email": "nobody@example.com", - "subject": "Bug", - "body": "Something broke" - }) - assert resp.status_code == 403 + with patch("routes.support.send_telegram_alert") as send_telegram_alert: + with patch("routes.support.send_ticket_confirmation") as send_ticket_confirmation: + resp = client.post("/api/support", json={ + "email": "nobody@example.com", + "subject": "Bug", + "body": "Something broke" + }) + + assert resp.status_code == 202 + assert resp.json() == GENERIC_SUPPORT_RESPONSE + send_telegram_alert.assert_not_called() + send_ticket_confirmation.assert_not_called() def test_support_creates_ticket(): @@ -46,5 +61,68 @@ def test_support_creates_ticket(): "android_version": "15", "app_version": "0.2.0" }) - assert resp.status_code == 200 - assert resp.json()["ticket_id"] > 0 + assert resp.status_code == 202 + assert resp.json() == GENERIC_SUPPORT_RESPONSE + + from db import get_db + + conn = get_db() + ticket = conn.execute("SELECT id FROM tickets").fetchone() + conn.close() + assert ticket["id"] > 0 + + +def test_support_matches_legacy_email_without_case_sensitivity(): + setup_test_user() + from app import app + from db import get_db + + conn = get_db() + conn.execute( + "UPDATE users SET email = ? WHERE email = ?", + ("Buyer@Example.com", "buyer@example.com"), + ) + conn.commit() + conn.close() + + client = TestClient(app) + with patch("routes.support.send_telegram_alert"): + with patch("routes.support.send_ticket_confirmation") as confirmation: + resp = client.post( + "/api/support", + json={ + "email": "buyer@example.com", + "subject": "Ctrl key bug", + "body": "Ctrl does not lock on double-tap", + }, + ) + + assert resp.status_code == 202 + assert resp.json() == GENERIC_SUPPORT_RESPONSE + + conn = get_db() + ticket = conn.execute("SELECT id FROM tickets").fetchone() + conn.close() + confirmation.assert_called_once_with( + "Buyer@Example.com", "Ctrl key bug", ticket["id"] + ) + + +def test_support_schedules_notifications_after_response(): + setup_test_user() + from routes.support import SupportRequest, create_ticket + + background_tasks = BackgroundTasks() + request = SupportRequest( + email="buyer@example.com", + subject="Keyboard issue", + body="The key did not respond", + ) + with patch("routes.support.send_telegram_alert") as telegram: + with patch("routes.support.send_ticket_confirmation") as confirmation: + response = asyncio.run(create_ticket(request, background_tasks)) + + assert response == GENERIC_SUPPORT_RESPONSE + assert len(background_tasks.tasks) == 2 + telegram.assert_not_called() + confirmation.assert_not_called() diff --git a/store/tests/test_unsubscribe.py b/store/tests/test_unsubscribe.py new file mode 100644 index 0000000..0196e24 --- /dev/null +++ b/store/tests/test_unsubscribe.py @@ -0,0 +1,105 @@ +import os +import sys +from urllib.parse import parse_qs, urlsplit + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from fastapi.testclient import TestClient + + +def unsubscribed_value(email): + from db import get_db + + conn = get_db() + user = conn.execute( + "SELECT unsubscribed FROM users WHERE email = ?", (email,) + ).fetchone() + conn.close() + return user["unsubscribed"] + + +def test_unsubscribe_url_preserves_plus_address(): + from app import app + from routes.unsubscribe import make_unsubscribe_url + + email = "reader+news@example.com" + url = urlsplit(make_unsubscribe_url(email)) + query = parse_qs(url.query) + + assert query["email"] == [email] + + client = TestClient(app) + response = client.get(f"{url.path}?{url.query}") + + assert response.status_code == 200 + + +def test_unsubscribe_get_requires_confirmation_before_database_update(): + from app import app + from db import get_db + from routes.unsubscribe import make_unsubscribe_url + + stored_email = "reader@example.com" + conn = get_db() + conn.execute("INSERT INTO users (email) VALUES (?)", (stored_email,)) + conn.commit() + conn.close() + + url = urlsplit(make_unsubscribe_url("Reader@Example.com")) + client = TestClient(app) + response = client.get(f"{url.path}?{url.query}") + + assert response.status_code == 200 + assert '
' in response.text + assert '' in response.text + assert "https://keyjawn.amditis.tech/favicon.svg" not in response.text + assert ' 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun! +Use Node.js 22.19 or newer. The GitHub Pages workflow builds with Node.js 24. -## 🚀 Project Structure +## Commands -Inside of your Astro project, you'll see the following folders and files: +Run commands from `website/`: -```text -/ -├── public/ -├── src/ -│ └── pages/ -│ └── index.astro -└── package.json +```sh +npm ci +npm run dev +npm run check +npm run build +npm run preview +npm audit --omit=dev ``` -Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name. - -There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components. - -Any static assets, like images, can be placed in the `public/` directory. - -## 🧞 Commands - -All commands are run from the root of the project, from a terminal: - -| Command | Action | -| :------------------------ | :----------------------------------------------- | -| `npm install` | Installs dependencies | -| `npm run dev` | Starts local dev server at `localhost:4321` | -| `npm run build` | Build your production site to `./dist/` | -| `npm run preview` | Preview your build locally, before deploying | -| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` | -| `npm run astro -- --help` | Get help using the Astro CLI | - -## 👀 Want to learn more? - -Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat). +`npm run check` performs the production build. The build writes the static site to `dist/`. +`npm audit --omit=dev` checks production dependencies for published advisories. + +## Public routes + +- `/` — platform overview and downloads +- `/features` — Android and iOS feature differences +- `/manual` — setup and usage +- `/support` — FAQ and known limits +- `/privacy` — Android and iOS privacy details +- `/pricing` — Android pricing and iOS beta availability +- `/changelog` — platform release status +- `/about` — project information +- `/thanks` — Android purchase confirmation +- `/blog/why-i-built-keyjawn` — dated Android product history + +## Release rules + +- Describe Android and iOS separately when behavior differs. +- State that the iOS terminal sends input to a remote SSH server. It does not run commands or browse files on the device. +- State that iOS image upload reads one copied image from the pasteboard. It does not request Photos or Files access. +- State that basic iOS keyboard typing works without Full Access. +- Verify every TestFlight or store URL before publication. +- Keep one source of FAQ data for visible answers and FAQ JSON-LD. +- Use the shared SVG favicon and provide Open Graph image alt text. + +App Store screenshots are release metadata. Do not place them in `public/` +unless a website page uses them. The current inventory is in +[`../ios/AppStore/Screenshots/README.md`](../ios/AppStore/Screenshots/README.md). diff --git a/website/package-lock.json b/website/package-lock.json index 1b5893f..5a551dc 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -9,60 +9,226 @@ "version": "0.0.1", "dependencies": { "@astrojs/sitemap": "^3.7.0", - "astro": "^5.17.1" + "astro": "^7.2.9" + }, + "engines": { + "node": ">=22.19.0" } }, - "node_modules/@astrojs/compiler": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.13.1.tgz", - "integrity": "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg==", - "license": "MIT" + "node_modules/@astrojs/compiler-binding": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding/-/compiler-binding-0.4.0.tgz", + "integrity": "sha512-x2RjDUuWfwLNtc3mjAdSRInwqh/rqbLar9cm/5FOMbHvmYZB7yfKewzSclAxWjIZsypJDXv1lhaP2WG+P8TK3g==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@astrojs/compiler-binding-darwin-arm64": "0.4.0", + "@astrojs/compiler-binding-darwin-x64": "0.4.0", + "@astrojs/compiler-binding-linux-arm64-gnu": "0.4.0", + "@astrojs/compiler-binding-linux-arm64-musl": "0.4.0", + "@astrojs/compiler-binding-linux-x64-gnu": "0.4.0", + "@astrojs/compiler-binding-linux-x64-musl": "0.4.0", + "@astrojs/compiler-binding-wasm32-wasi": "0.4.0", + "@astrojs/compiler-binding-win32-arm64-msvc": "0.4.0", + "@astrojs/compiler-binding-win32-x64-msvc": "0.4.0" + } + }, + "node_modules/@astrojs/compiler-binding-darwin-arm64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-arm64/-/compiler-binding-darwin-arm64-0.4.0.tgz", + "integrity": "sha512-ZVUwHundaQyFNjE6uoa0usaC0WOCitDCLS/4mdb4rOiJXwVUuKJBMxI5WMzXLWmamsXtK/Z//ifLXvV5Yeh4Hw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-darwin-x64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-x64/-/compiler-binding-darwin-x64-0.4.0.tgz", + "integrity": "sha512-FI6G8AY8u6fR1SI/QRR5yGMwtvZwP34CDmZpZ5HwJGa50UM1VISTLhqkhV4a476pmgd25X1Aur2dqw6hUnrlKA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-linux-arm64-gnu": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-gnu/-/compiler-binding-linux-arm64-gnu-0.4.0.tgz", + "integrity": "sha512-lB9gLFJK7m82EnjaU8nlRBEfcwGNeHidW3sSjODTUjMNaoewVuUz9fwwdY5M4jiSXIqWLH3yl6TX8FTDKA74Sw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-linux-arm64-musl": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-musl/-/compiler-binding-linux-arm64-musl-0.4.0.tgz", + "integrity": "sha512-HPbvWqbxFxyaoQJhLxCaSjtYBx9KBo7JGVzEFZCmMl968a2PsSH0UfiODYgYPXofTOIsIH2aoCcrHXML0IA3ig==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-linux-x64-gnu": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-gnu/-/compiler-binding-linux-x64-gnu-0.4.0.tgz", + "integrity": "sha512-tQKolMxoJ/+0AmLWm1PmJ/i+z3i10ZU1bNuVjEDulCf48azEMtUNjTZgHJ5MPtpYRNc7dlETr8QujUfduzoC7Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-linux-x64-musl": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-musl/-/compiler-binding-linux-x64-musl-0.4.0.tgz", + "integrity": "sha512-5v5YymudsxMHp3NBLCS8BUlu5CRqeLtWD9cKS/4nIhIEHCbpz9okmVV6I0HWqmBAPhWYcDa3vw/vltYPrOQCTA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-wasm32-wasi": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-wasm32-wasi/-/compiler-binding-wasm32-wasi-0.4.0.tgz", + "integrity": "sha512-m/phuH3x3PREvv1OnkM44NoPh4MatUadix1fB1u5SvMLCyDTUZykDJbKnWf1cjnYmHdlB8HcjTjl6JrCqAIXcw==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.2.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@astrojs/compiler-binding-win32-arm64-msvc": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-arm64-msvc/-/compiler-binding-win32-arm64-msvc-0.4.0.tgz", + "integrity": "sha512-B9zYf3okEY83kM8gydlpH2BHP00w4ifxPqlYlWrgTwuD6wnkrJDCwBlgy1q31cERjCJRXN1lrE2VmkLvFjv/6g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-win32-x64-msvc": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-x64-msvc/-/compiler-binding-win32-x64-msvc-0.4.0.tgz", + "integrity": "sha512-zB0Nrv0dGc0zZWPGDRmmETTPhDRqyZjAjk+gWMlVrJX5U89obpB3VUUE1ZiHxOCN5LQojeLK6O8L/dnoHolvNQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-rs": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-rs/-/compiler-rs-0.4.0.tgz", + "integrity": "sha512-koVikeon1kreEy+/JzLQRy3vzHHQVOjycs4degg4vFufKApZOwMZvSSAEztYNhmcQVfNVsVZZI4cEge3cexAbQ==", + "license": "MIT", + "dependencies": { + "@astrojs/compiler-binding": "0.4.0" + }, + "engines": { + "node": ">=22.12.0" + } }, "node_modules/@astrojs/internal-helpers": { - "version": "0.7.5", - "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.7.5.tgz", - "integrity": "sha512-vreGnYSSKhAjFJCWAwe/CNhONvoc5lokxtRoZims+0wa3KbHBdPHSSthJsKxPd8d/aic6lWKpRTYGY/hsgK6EA==", - "license": "MIT" + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.10.4.tgz", + "integrity": "sha512-nozZSy/mKYLqe4YrqbKtdOszedAfXYCtw3wZ0d+CAjz4GqQ4L9rl1ltIL5BlgwmYVinJg/RZ0MgGuWOdlyRZlA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.4", + "@types/mdast": "^4.0.4", + "js-yaml": "^4.3.0", + "picomatch": "^4.0.4", + "retext-smartypants": "^6.2.0", + "shiki": "^4.0.2", + "smol-toml": "^1.6.0", + "unified": "^11.0.5" + } }, - "node_modules/@astrojs/markdown-remark": { - "version": "6.3.10", - "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-6.3.10.tgz", - "integrity": "sha512-kk4HeYR6AcnzC4QV8iSlOfh+N8TZ3MEStxPyenyCtemqn8IpEATBFMTJcfrNW32dgpt6MY3oCkMM/Tv3/I4G3A==", + "node_modules/@astrojs/markdown-satteri": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-satteri/-/markdown-satteri-0.3.8.tgz", + "integrity": "sha512-n8ItpFTCmlDsVR5+rwDmehSf+jFCYLWmZiisZNGuF7xILqYhsVeBfcha4qgS2Seq3fAc9Tm58ZVrvqFQ6RQgRQ==", "license": "MIT", "dependencies": { - "@astrojs/internal-helpers": "0.7.5", - "@astrojs/prism": "3.3.0", + "@astrojs/internal-helpers": "0.10.4", + "@astrojs/prism": "4.0.2", "github-slugger": "^2.0.0", - "hast-util-from-html": "^2.0.3", - "hast-util-to-text": "^4.0.2", - "import-meta-resolve": "^4.2.0", - "js-yaml": "^4.1.1", - "mdast-util-definitions": "^6.0.0", - "rehype-raw": "^7.0.0", - "rehype-stringify": "^10.0.1", - "remark-gfm": "^4.0.1", - "remark-parse": "^11.0.0", - "remark-rehype": "^11.1.2", - "remark-smartypants": "^3.0.2", - "shiki": "^3.19.0", - "smol-toml": "^1.5.2", - "unified": "^11.0.5", - "unist-util-remove-position": "^5.0.0", - "unist-util-visit": "^5.0.0", - "unist-util-visit-parents": "^6.0.2", - "vfile": "^6.0.3" + "satteri": "^0.10.3" } }, "node_modules/@astrojs/prism": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-3.3.0.tgz", - "integrity": "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.0.2.tgz", + "integrity": "sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==", "license": "MIT", "dependencies": { "prismjs": "^1.30.0" }, "engines": { - "node": "18.20.8 || ^20.3.0 || >=22.0.0" + "node": ">=22.12.0" } }, "node_modules/@astrojs/sitemap": { @@ -77,18 +243,15 @@ } }, "node_modules/@astrojs/telemetry": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.0.tgz", - "integrity": "sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.3.tgz", + "integrity": "sha512-C1TLn5sPJr0x4vk56piHWKbnqlEB8BKyte5Y45V02U+D7BGO5eMqZDH5aPjnkXQWJggvmsTXxH03QMZ9NgWLzQ==", "license": "MIT", "dependencies": { - "ci-info": "^4.2.0", - "debug": "^4.4.0", - "dlv": "^1.1.3", + "ci-info": "^4.4.0", "dset": "^3.1.4", - "is-docker": "^3.0.0", - "is-wsl": "^3.1.0", - "which-pm-runs": "^1.1.0" + "is-docker": "^4.0.0", + "package-manager-detector": "^1.6.0" }, "engines": { "node": "18.20.8 || ^20.3.0 || >=22.0.0" @@ -140,6 +303,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bruits/satteri-darwin-arm64": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-arm64/-/satteri-darwin-arm64-0.10.5.tgz", + "integrity": "sha512-27KTVl4TJkVahMy/ohyA7qd4938G5UNneFUz/PsScYfpIhj0IVAS23mpcJXdPF44sa6nva198lmV/cKIb2YPyA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@bruits/satteri-darwin-x64": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-x64/-/satteri-darwin-x64-0.10.5.tgz", + "integrity": "sha512-IjnLe3nKspq6qaeqGgjT7MT8VrTV74yWRlaag7ZdNsI8TDAYZ0iPxMCo+9KQZHUk5EyVB+reBI/PFWL5KuFw9Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@bruits/satteri-linux-arm64-gnu": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-gnu/-/satteri-linux-arm64-gnu-0.10.5.tgz", + "integrity": "sha512-glkYXZCJywjP13v67eAyAMSJdF+ncvEbYvgi/wOtffL9tQ27lr/zsyzUfgs+ovjJ9d8JNQKiXeiArJcX8PJL9w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@bruits/satteri-linux-arm64-musl": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-musl/-/satteri-linux-arm64-musl-0.10.5.tgz", + "integrity": "sha512-yWdgG1g17Nh2QyGVlFUxGRa3FEFwiMcpZEyMNWkbM3deC94cmVc+/i9OuyFpdKuWo3GkgoCtYVOoxk1uCnCZIA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@bruits/satteri-linux-x64-gnu": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-gnu/-/satteri-linux-x64-gnu-0.10.5.tgz", + "integrity": "sha512-FVaLoPT1fBgGl0J+AYebyyXJYBachGl8Oyyrf1lye4RTqCB4S0Gwkj1uM9RJyThUOvx5VUmAT1CnNh1SFHA+kw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@bruits/satteri-linux-x64-musl": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-musl/-/satteri-linux-x64-musl-0.10.5.tgz", + "integrity": "sha512-EHpVAx2bqW3GINHTKkljtxVfQmVDGWIuwOYOP5YghTj+0PkBa2o8oKPRtQ9Kbsr1Fye8jtUcDjhwj2jMNugZKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@bruits/satteri-wasm32-wasi": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-wasm32-wasi/-/satteri-wasm32-wasi-0.10.5.tgz", + "integrity": "sha512-ypz8c/Zmipxp4IoeDa228Gstv6TLzVmNs3yC6wKCoNSOjx1iwpgzu87Y3hTkXFdwChVGU85qeUDuOIarGUZQLw==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.2.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@bruits/satteri-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@bruits/satteri-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@bruits/satteri-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@bruits/satteri-win32-arm64-msvc": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-arm64-msvc/-/satteri-win32-arm64-msvc-0.10.5.tgz", + "integrity": "sha512-siTV88nb0LRqNpkL2gXboqCwVdq95sLtzMHS1/3eONV2gLbB3NAK46wmSMvCO/yquBvI2lvaFIfd8P12ecsxBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@bruits/satteri-win32-x64-msvc": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-x64-msvc/-/satteri-win32-x64-msvc-0.10.5.tgz", + "integrity": "sha512-C3IfPvfvMXmlzBxaMPKFS1XiuV9pu2mC7YqkPk7PSvTgPZ8gbdASIpHpztDLvTTQjqZ0z1Ol8tK5X+V6XXC0wQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@capsizecss/unpack": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@capsizecss/unpack/-/unpack-4.0.0.tgz", @@ -152,20 +468,71 @@ "node": ">=18" } }, + "node_modules/@clack/core": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.3.tgz", + "integrity": "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==", + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.7.0.tgz", + "integrity": "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.4.3", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", "cpu": [ "ppc64" ], @@ -179,9 +546,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", "cpu": [ "arm" ], @@ -195,9 +562,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ "arm64" ], @@ -211,9 +578,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", "cpu": [ "x64" ], @@ -227,9 +594,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ "arm64" ], @@ -243,9 +610,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ "x64" ], @@ -259,9 +626,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ "arm64" ], @@ -275,9 +642,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ "x64" ], @@ -291,9 +658,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ "arm" ], @@ -307,9 +674,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ "arm64" ], @@ -323,9 +690,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ "ia32" ], @@ -339,9 +706,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ "loong64" ], @@ -355,9 +722,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ "mips64el" ], @@ -371,9 +738,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", "cpu": [ "ppc64" ], @@ -387,9 +754,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", "cpu": [ "riscv64" ], @@ -403,9 +770,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ "s390x" ], @@ -419,9 +786,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ "x64" ], @@ -435,9 +802,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", "cpu": [ "arm64" ], @@ -451,9 +818,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], @@ -467,9 +834,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", "cpu": [ "arm64" ], @@ -483,9 +850,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", "cpu": [ "x64" ], @@ -499,9 +866,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ "arm64" ], @@ -515,9 +882,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", "cpu": [ "x64" ], @@ -531,9 +898,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ "arm64" ], @@ -547,9 +914,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ "ia32" ], @@ -563,9 +930,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], @@ -579,9 +946,9 @@ } }, "node_modules/@img/colour": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", - "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", "optional": true, "engines": { @@ -589,9 +956,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", "cpu": [ "arm64" ], @@ -601,19 +968,19 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "@img/sharp-libvips-darwin-arm64": "1.3.3" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", "cpu": [ "x64" ], @@ -623,20 +990,39 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "@img/sharp-libvips-darwin-x64": "1.3.3" } }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", - "cpu": [ + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "cpu": [ "arm64" ], "license": "LGPL-3.0-or-later", @@ -649,9 +1035,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", "cpu": [ "x64" ], @@ -665,9 +1051,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", "cpu": [ "arm" ], @@ -681,9 +1067,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", "cpu": [ "arm64" ], @@ -697,9 +1083,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", "cpu": [ "ppc64" ], @@ -713,9 +1099,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", "cpu": [ "riscv64" ], @@ -729,9 +1115,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", "cpu": [ "s390x" ], @@ -745,9 +1131,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", "cpu": [ "x64" ], @@ -761,9 +1147,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", "cpu": [ "arm64" ], @@ -777,9 +1163,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", "cpu": [ "x64" ], @@ -793,9 +1179,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", "cpu": [ "arm" ], @@ -805,19 +1191,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "@img/sharp-libvips-linux-arm": "1.3.3" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", "cpu": [ "arm64" ], @@ -827,19 +1213,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "@img/sharp-libvips-linux-arm64": "1.3.3" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", "cpu": [ "ppc64" ], @@ -849,19 +1235,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "@img/sharp-libvips-linux-ppc64": "1.3.3" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", "cpu": [ "riscv64" ], @@ -871,19 +1257,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "@img/sharp-libvips-linux-riscv64": "1.3.3" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", "cpu": [ "s390x" ], @@ -893,19 +1279,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "@img/sharp-libvips-linux-s390x": "1.3.3" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", "cpu": [ "x64" ], @@ -915,19 +1301,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "@img/sharp-libvips-linux-x64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", "cpu": [ "arm64" ], @@ -937,19 +1323,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", "cpu": [ "x64" ], @@ -959,38 +1345,54 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", "cpu": [ "wasm32" ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.7.0" + "@img/sharp-wasm32": "0.35.4" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", "cpu": [ "arm64" ], @@ -1000,16 +1402,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", "cpu": [ "ia32" ], @@ -1019,16 +1421,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", "cpu": [ "x64" ], @@ -1038,7 +1440,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -1050,44 +1452,46 @@ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, - "node_modules/@oslojs/encoding": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", - "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", - "license": "MIT" - }, - "node_modules/@rollup/pluginutils": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", - "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz", + "integrity": "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==", "license": "MIT", + "optional": true, "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" + "@tybys/wasm-util": "^0.10.3" }, "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, - "node_modules/@rollup/pluginutils/node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "node_modules/@oslojs/encoding": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", + "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", "license": "MIT" }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "node_modules/@oxc-project/types": { + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz", + "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz", + "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==", "cpu": [ "arm" ], @@ -1095,12 +1499,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz", + "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==", "cpu": [ "arm64" ], @@ -1108,12 +1515,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz", + "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==", "cpu": [ "arm64" ], @@ -1121,12 +1531,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz", + "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==", "cpu": [ "x64" ], @@ -1134,25 +1547,15 @@ "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", - "cpu": [ - "arm64" ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz", + "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==", "cpu": [ "x64" ], @@ -1160,25 +1563,15 @@ "optional": true, "os": [ "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", - "cpu": [ - "arm" ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz", + "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==", "cpu": [ "arm" ], @@ -1186,25 +1579,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", - "cpu": [ - "arm64" ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz", + "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==", "cpu": [ "arm64" ], @@ -1212,51 +1595,31 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", - "cpu": [ - "loong64" ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz", + "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==", "cpu": [ - "loong64" + "arm64" ], "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", - "cpu": [ - "ppc64" ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz", + "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==", "cpu": [ "ppc64" ], @@ -1264,38 +1627,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", - "cpu": [ - "riscv64" ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz", + "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==", "cpu": [ "s390x" ], @@ -1303,12 +1643,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz", + "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==", "cpu": [ "x64" ], @@ -1316,12 +1659,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz", + "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==", "cpu": [ "x64" ], @@ -1329,25 +1675,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", - "cpu": [ - "x64" ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz", + "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==", "cpu": [ "arm64" ], @@ -1355,12 +1691,15 @@ "optional": true, "os": [ "openharmony" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz", + "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==", "cpu": [ "arm64" ], @@ -1368,25 +1707,15 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", - "cpu": [ - "ia32" ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz", + "integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==", "cpu": [ "x64" ], @@ -1394,80 +1723,109 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", - "cpu": [ - "x64" ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" }, "node_modules/@shikijs/core": { - "version": "3.22.0", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.22.0.tgz", - "integrity": "sha512-iAlTtSDDbJiRpvgL5ugKEATDtHdUVkqgHDm/gbD2ZS9c88mx7G1zSYjjOxp5Qa0eaW0MAQosFRmJSk354PRoQA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.4.3.tgz", + "integrity": "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.22.0", + "@shikijs/primitive": "4.4.3", + "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4", + "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" } }, "node_modules/@shikijs/engine-javascript": { - "version": "3.22.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.22.0.tgz", - "integrity": "sha512-jdKhfgW9CRtj3Tor0L7+yPwdG3CgP7W+ZEqSsojrMzCjD1e0IxIbwUMDDpYlVBlC08TACg4puwFGkZfLS+56Tw==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.4.3.tgz", + "integrity": "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.22.0", + "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", - "oniguruma-to-es": "^4.3.4" + "oniguruma-to-es": "^4.3.6" + }, + "engines": { + "node": ">=20" } }, "node_modules/@shikijs/engine-oniguruma": { - "version": "3.22.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.22.0.tgz", - "integrity": "sha512-DyXsOG0vGtNtl7ygvabHd7Mt5EY8gCNqR9Y7Lpbbd/PbJvgWrqaKzH1JW6H6qFkuUa8aCxoiYVv8/YfFljiQxA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.4.3.tgz", + "integrity": "sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.22.0", + "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" } }, "node_modules/@shikijs/langs": { - "version": "3.22.0", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.22.0.tgz", - "integrity": "sha512-x/42TfhWmp6H00T6uwVrdTJGKgNdFbrEdhaDwSR5fd5zhQ1Q46bHq9EO61SCEWJR0HY7z2HNDMaBZp8JRmKiIA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.4.3.tgz", + "integrity": "sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.4.3.tgz", + "integrity": "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.22.0" + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" } }, "node_modules/@shikijs/themes": { - "version": "3.22.0", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.22.0.tgz", - "integrity": "sha512-o+tlOKqsr6FE4+mYJG08tfCFDS+3CG20HbldXeVoyP+cYSUxDhrFf3GPjE60U55iOkkjbpY2uC3It/eeja35/g==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.4.3.tgz", + "integrity": "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.22.0" + "@shikijs/types": "4.4.3" + }, + "engines": { + "node": ">=20" } }, "node_modules/@shikijs/types": { - "version": "3.22.0", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.22.0.tgz", - "integrity": "sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.4.3.tgz", + "integrity": "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==", "license": "MIT", "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" } }, "node_modules/@shikijs/vscode-textmate": { @@ -1476,25 +1834,35 @@ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "license": "MIT" }, - "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "license": "MIT", + "optional": true, "dependencies": { - "@types/ms": "*" + "tslib": "^2.4.0" } }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", "license": "MIT", "dependencies": { "@types/unist": "*" @@ -1509,12 +1877,6 @@ "@types/unist": "*" } }, - "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "license": "MIT" - }, "node_modules/@types/nlcst": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/nlcst/-/nlcst-2.0.3.tgz", @@ -1549,120 +1911,46 @@ "license": "MIT" }, "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", "license": "ISC" }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "node_modules/am-i-vibing": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/am-i-vibing/-/am-i-vibing-0.4.0.tgz", + "integrity": "sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg==", "license": "MIT", - "bin": { - "acorn": "bin/acorn" + "dependencies": { + "process-ancestry": "^0.1.0" }, - "engines": { - "node": ">=0.4.0" + "bin": { + "am-i-vibing": "dist/cli.mjs" } }, - "node_modules/ansi-align": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", - "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "license": "ISC", "dependencies": { - "string-width": "^4.1.0" + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" } }, - "node_modules/ansi-align/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-align/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/ansi-align/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-align/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/arg": { @@ -1686,91 +1974,71 @@ "node": ">= 0.4" } }, - "node_modules/array-iterate": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/array-iterate/-/array-iterate-2.0.1.tgz", - "integrity": "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/astro": { - "version": "5.17.2", - "resolved": "https://registry.npmjs.org/astro/-/astro-5.17.2.tgz", - "integrity": "sha512-7jnMqGo53hOQNwo1N/wqeOvUp8wwW/p+DeerSjSkHNx8L/1mhy6P7rVo7EhdmF8DpKqw0tl/B5Fx1WcIzg1ysA==", + "version": "7.2.9", + "resolved": "https://registry.npmjs.org/astro/-/astro-7.2.9.tgz", + "integrity": "sha512-o5nZFo/bieF6rp4x9sQSLhI7GO7Bahpld38Y4LvX27nFoxNiuttjI+Hea81w5ksORLHgdPUlQpU9obPz5QRa8g==", "license": "MIT", "dependencies": { - "@astrojs/compiler": "^2.13.0", - "@astrojs/internal-helpers": "0.7.5", - "@astrojs/markdown-remark": "6.3.10", - "@astrojs/telemetry": "3.3.0", + "@astrojs/compiler-rs": "^0.4.0", + "@astrojs/internal-helpers": "0.10.4", + "@astrojs/markdown-satteri": "0.3.8", + "@astrojs/telemetry": "3.3.3", "@capsizecss/unpack": "^4.0.0", + "@clack/prompts": "^1.1.0", "@oslojs/encoding": "^1.1.0", - "@rollup/pluginutils": "^5.3.0", - "acorn": "^8.15.0", + "am-i-vibing": "^0.4.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", - "boxen": "8.0.1", - "ci-info": "^4.3.1", + "ci-info": "^4.4.0", "clsx": "^2.1.1", - "common-ancestor-path": "^1.0.1", - "cookie": "^1.1.1", - "cssesc": "^3.0.0", - "debug": "^4.4.3", - "deterministic-object-hash": "^2.0.2", - "devalue": "^5.6.2", - "diff": "^8.0.3", - "dlv": "^1.1.3", + "common-ancestor-path": "^2.0.0", + "cookie": "^2.0.1", + "devalue": "^5.8.1", + "diff": "^9.0.0", "dset": "^3.1.4", - "es-module-lexer": "^1.7.0", - "esbuild": "^0.27.0", - "estree-walker": "^3.0.3", + "es-module-lexer": "^2.0.0", + "esbuild": "^0.28.0", + "find-proc": "0.1.0", "flattie": "^1.1.1", - "fontace": "~0.4.0", + "fontace": "~0.4.1", + "get-tsconfig": "5.0.0-beta.4", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", - "import-meta-resolve": "^4.2.0", - "js-yaml": "^4.1.1", - "magic-string": "^0.30.21", - "magicast": "^0.5.1", + "js-yaml": "^4.3.0", + "jsonc-parser": "^3.3.1", + "magic-string": "^1.0.0", + "magicast": "^0.5.2", "mrmime": "^2.0.1", - "neotraverse": "^0.6.18", - "p-limit": "^6.2.0", - "p-queue": "^8.1.1", + "neotraverse": "^1.0.1", + "obug": "^2.1.1", + "p-limit": "^7.3.0", + "p-queue": "^9.1.0", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", - "picomatch": "^4.0.3", - "prompts": "^2.4.2", - "rehype": "^13.0.2", - "semver": "^7.7.3", - "shiki": "^3.21.0", + "picomatch": "^4.0.4", + "semver": "^7.7.4", + "shiki": "^4.0.2", "smol-toml": "^1.6.0", - "svgo": "^4.0.0", - "tinyexec": "^1.0.2", + "svgo": "^4.0.1", + "tinyclip": "^0.1.12", + "tinyexec": "^1.0.4", "tinyglobby": "^0.2.15", - "tsconfck": "^3.1.6", "ultrahtml": "^1.6.0", - "unifont": "~0.7.3", - "unist-util-visit": "^5.0.0", - "unstorage": "^1.17.4", - "vfile": "^6.0.3", - "vite": "^6.4.1", - "vitefu": "^1.1.1", + "unifont": "~0.7.5", + "unstorage": "^1.17.5", + "vite": "^8.0.13", + "vitefu": "^1.1.2", "xxhash-wasm": "^1.1.0", - "yargs-parser": "^21.1.1", - "yocto-spinner": "^0.2.3", - "zod": "^3.25.76", - "zod-to-json-schema": "^3.25.1", - "zod-to-ts": "^1.2.0" + "yargs-parser": "^22.0.0", + "zod": "^4.3.6" }, "bin": { - "astro": "astro.js" + "astro": "bin/astro.mjs" }, "engines": { - "node": "18.20.8 || ^20.3.0 || >=22.0.0", + "node": ">=22.12.0", "npm": ">=9.6.5", "pnpm": ">=7.1.0" }, @@ -1779,7 +2047,24 @@ "url": "https://opencollective.com/astrodotbuild" }, "optionalDependencies": { - "sharp": "^0.34.0" + "sharp": "^0.35.4" + }, + "peerDependencies": { + "@astrojs/markdown-remark": "7.2.4" + }, + "peerDependenciesMeta": { + "@astrojs/markdown-remark": { + "optional": true + } + } + }, + "node_modules/astro/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" } }, "node_modules/axobject-query": { @@ -1801,52 +2086,12 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/base-64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", - "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==", - "license": "MIT" - }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "license": "ISC" }, - "node_modules/boxen": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/boxen/-/boxen-8.0.1.tgz", - "integrity": "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==", - "license": "MIT", - "dependencies": { - "ansi-align": "^3.0.1", - "camelcase": "^8.0.0", - "chalk": "^5.3.0", - "cli-boxes": "^3.0.0", - "string-width": "^7.2.0", - "type-fest": "^4.21.0", - "widest-line": "^5.0.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/camelcase": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", - "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -1857,28 +2102,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/character-entities": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", - "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/character-entities-html4": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", @@ -1929,18 +2152,6 @@ "node": ">=8" } }, - "node_modules/cli-boxes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", - "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -1970,18 +2181,21 @@ } }, "node_modules/common-ancestor-path": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-1.0.1.tgz", - "integrity": "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==", - "license": "ISC" + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-2.0.0.tgz", + "integrity": "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">= 18" + } }, "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", "license": "MIT", "engines": { - "node": ">=18" + "node": ">=22" }, "funding": { "type": "opencollective", @@ -1989,9 +2203,9 @@ } }, "node_modules/cookie-es": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.2.tgz", - "integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.3.tgz", + "integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==", "license": "MIT" }, "node_modules/crossws": { @@ -2004,38 +2218,38 @@ } }, "node_modules/css-select": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", - "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz", + "integrity": "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==", "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0", - "css-what": "^6.1.0", - "domhandler": "^5.0.2", - "domutils": "^3.0.1", - "nth-check": "^2.0.1" + "css-what": "^7.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "nth-check": "^2.1.1" }, "funding": { "url": "https://github.com/sponsors/fb55" } }, "node_modules/css-tree": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "license": "MIT", "dependencies": { - "mdn-data": "2.12.2", - "source-map-js": "^1.0.1" + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, "node_modules/css-what": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", - "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-7.0.0.tgz", + "integrity": "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==", "license": "BSD-2-Clause", "engines": { "node": ">= 6" @@ -2044,18 +2258,6 @@ "url": "https://github.com/sponsors/fb55" } }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/csso": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", @@ -2089,40 +2291,10 @@ "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", "license": "CC0-1.0" }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decode-named-character-reference": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", - "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", - "license": "MIT", - "dependencies": { - "character-entities": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/defu": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", - "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", "license": "MIT" }, "node_modules/dequal": { @@ -2145,27 +2317,14 @@ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=8" } }, - "node_modules/deterministic-object-hash": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/deterministic-object-hash/-/deterministic-object-hash-2.0.2.tgz", - "integrity": "sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ==", - "license": "MIT", - "dependencies": { - "base-64": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/devalue": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz", - "integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==", + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.2.tgz", + "integrity": "sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w==", "license": "MIT" }, "node_modules/devlop": { @@ -2182,20 +2341,14 @@ } }, "node_modules/diff": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", - "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" } }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "license": "MIT" - }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -2210,18 +2363,6 @@ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" } }, - "node_modules/dom-serializer/node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/domelementtype": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", @@ -2272,16 +2413,10 @@ "node": ">=4" } }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "license": "MIT" - }, "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -2291,15 +2426,15 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "license": "MIT" }, "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "hasInstallScript": true, "license": "MIT", "bin": { @@ -2309,53 +2444,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" - } - }, - "node_modules/escape-string-regexp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, "node_modules/eventemitter3": { @@ -2370,6 +2484,30 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2387,6 +2525,15 @@ } } }, + "node_modules/find-proc": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/find-proc/-/find-proc-0.1.0.tgz", + "integrity": "sha512-OaOpEYv2PiQ7SQ5LIrl+deA1XaWcxEjnpM6VuWXTUvn+teIXxeFTLDmu18/zDQpFmHN4o3oDBX+BT0AGwEhemg==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/flattie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/flattie/-/flattie-1.1.1.tgz", @@ -2431,16 +2578,19 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/get-east-asian-width": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", - "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "node_modules/get-tsconfig": { + "version": "5.0.0-beta.4", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.4.tgz", + "integrity": "sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==", "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, "engines": { - "node": ">=18" + "node": ">=20.20.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, "node_modules/github-slugger": { @@ -2450,14 +2600,14 @@ "license": "ISC" }, "node_modules/h3": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.5.tgz", - "integrity": "sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz", + "integrity": "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==", "license": "MIT", "dependencies": { - "cookie-es": "^1.2.2", + "cookie-es": "^1.2.3", "crossws": "^0.3.5", - "defu": "^6.1.4", + "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", @@ -2466,48 +2616,33 @@ "uncrypto": "^0.1.3" } }, - "node_modules/hast-util-from-html": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", - "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "devlop": "^1.1.0", - "hast-util-from-parse5": "^8.0.0", - "parse5": "^7.0.0", - "vfile": "^6.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-from-parse5": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", - "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", - "devlop": "^1.0.0", - "hastscript": "^9.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", - "vfile": "^6.0.0", - "vfile-location": "^5.0.0", - "web-namespaces": "^2.0.0" + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" } }, - "node_modules/hast-util-is-element": { + "node_modules/hast-util-whitespace": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", - "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", "license": "MIT", "dependencies": { "@types/hast": "^3.0.0" @@ -2517,132 +2652,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/hast-util-parse-selector": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", - "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-raw": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", - "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "@ungap/structured-clone": "^1.0.0", - "hast-util-from-parse5": "^8.0.0", - "hast-util-to-parse5": "^8.0.0", - "html-void-elements": "^3.0.0", - "mdast-util-to-hast": "^13.0.0", - "parse5": "^7.0.0", - "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0", - "web-namespaces": "^2.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-to-html": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", - "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "ccount": "^2.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-whitespace": "^3.0.0", - "html-void-elements": "^3.0.0", - "mdast-util-to-hast": "^13.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "stringify-entities": "^4.0.0", - "zwitch": "^2.0.4" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-to-parse5": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", - "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "devlop": "^1.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "web-namespaces": "^2.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-to-text": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", - "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "hast-util-is-element": "^3.0.0", - "unist-util-find-after": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-whitespace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", - "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hastscript": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", - "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-parse-selector": "^4.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/html-escaper": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", @@ -2665,16 +2674,6 @@ "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", "license": "BSD-2-Clause" }, - "node_modules/import-meta-resolve": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", - "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/iron-webcrypto": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", @@ -2685,42 +2684,15 @@ } }, "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-4.0.0.tgz", + "integrity": "sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA==", "license": "MIT", "bin": { "is-docker": "cli.js" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "license": "MIT", - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -2738,25 +2710,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-wsl": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", - "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", - "license": "MIT", - "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -2765,232 +2732,288 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/longest-streak": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", - "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" }, - "node_modules/magicast": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", - "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", - "license": "MIT", + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "source-map-js": "^1.2.1" - } - }, - "node_modules/markdown-table": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", - "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", - "license": "MIT", + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/mdast-util-definitions": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-6.0.0.tgz", - "integrity": "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "unist-util-visit": "^5.0.0" + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://opencollective.com/parcel" } }, - "node_modules/mdast-util-find-and-replace": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", - "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "escape-string-regexp": "^5.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://opencollective.com/parcel" } }, - "node_modules/mdast-util-from-markdown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", - "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark": "^4.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unist-util-stringify-position": "^4.0.0" + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://opencollective.com/parcel" } }, - "node_modules/mdast-util-gfm": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", - "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", - "license": "MIT", - "dependencies": { - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-gfm-autolink-literal": "^2.0.0", - "mdast-util-gfm-footnote": "^2.0.0", - "mdast-util-gfm-strikethrough": "^2.0.0", - "mdast-util-gfm-table": "^2.0.0", - "mdast-util-gfm-task-list-item": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://opencollective.com/parcel" } }, - "node_modules/mdast-util-gfm-autolink-literal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", - "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "ccount": "^2.0.0", - "devlop": "^1.0.0", - "mdast-util-find-and-replace": "^3.0.0", - "micromark-util-character": "^2.0.0" + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://opencollective.com/parcel" } }, - "node_modules/mdast-util-gfm-footnote": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", - "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.1.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0" + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://opencollective.com/parcel" } }, - "node_modules/mdast-util-gfm-strikethrough": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", - "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://opencollective.com/parcel" } }, - "node_modules/mdast-util-gfm-table": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", - "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "markdown-table": "^3.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://opencollective.com/parcel" } }, - "node_modules/mdast-util-gfm-task-list-item": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", - "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://opencollective.com/parcel" } }, - "node_modules/mdast-util-phrasing": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", - "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "unist-util-is": "^6.0.0" + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz", + "integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" } }, "node_modules/mdast-util-to-hast": { @@ -3014,50 +3037,16 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/mdast-util-to-markdown": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", - "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "longest-streak": "^3.0.0", - "mdast-util-phrasing": "^4.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "unist-util-visit": "^5.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", - "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", "license": "CC0-1.0" }, - "node_modules/micromark": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", - "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", "funding": [ { "type": "GitHub Sponsors", @@ -3070,29 +3059,14 @@ ], "license": "MIT", "dependencies": { - "@types/debug": "^4.0.0", - "debug": "^4.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, - "node_modules/micromark-core-commonmark": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", - "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", "funding": [ { "type": "GitHub Sponsors", @@ -3103,151 +3077,12 @@ "url": "https://opencollective.com/unified" } ], - "license": "MIT", - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-factory-destination": "^2.0.0", - "micromark-factory-label": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-factory-title": "^2.0.0", - "micromark-factory-whitespace": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-html-tag-name": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-gfm": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", - "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", - "license": "MIT", - "dependencies": { - "micromark-extension-gfm-autolink-literal": "^2.0.0", - "micromark-extension-gfm-footnote": "^2.0.0", - "micromark-extension-gfm-strikethrough": "^2.0.0", - "micromark-extension-gfm-table": "^2.0.0", - "micromark-extension-gfm-tagfilter": "^2.0.0", - "micromark-extension-gfm-task-list-item": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-autolink-literal": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", - "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-footnote": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", - "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-strikethrough": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", - "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-table": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", - "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-tagfilter": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", - "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", - "license": "MIT", - "dependencies": { - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-task-list-item": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", - "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } + "license": "MIT" }, - "node_modules/micromark-factory-destination": { + "node_modules/micromark-util-sanitize-uri": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", - "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", "funding": [ { "type": "GitHub Sponsors", @@ -3261,14 +3096,14 @@ "license": "MIT", "dependencies": { "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" } }, - "node_modules/micromark-factory-label": { + "node_modules/micromark-util-symbol": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", - "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", "funding": [ { "type": "GitHub Sponsors", @@ -3279,18 +3114,12 @@ "url": "https://opencollective.com/unified" } ], - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } + "license": "MIT" }, - "node_modules/micromark-factory-space": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", - "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", "funding": [ { "type": "GitHub Sponsors", @@ -3301,482 +3130,175 @@ "url": "https://opencollective.com/unified" } ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } + "license": "MIT" }, - "node_modules/micromark-factory-title": { + "node_modules/mrmime": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", - "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", "license": "MIT", - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" + "engines": { + "node": ">=10" } }, - "node_modules/micromark-factory-whitespace": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", - "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" + "type": "github", + "url": "https://github.com/sponsors/ai" } ], "license": "MIT", - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], + "node_modules/neotraverse": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-1.0.1.tgz", + "integrity": "sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w==", "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" + "engines": { + "node": ">= 10" } }, - "node_modules/micromark-util-chunked": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", - "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], + "node_modules/nlcst-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz", + "integrity": "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==", "license": "MIT", "dependencies": { - "micromark-util-symbol": "^2.0.0" + "@types/nlcst": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/micromark-util-classify-character": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", - "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "license": "MIT" + }, + "node_modules/node-mock-http": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.5.tgz", + "integrity": "sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw==", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" + "engines": { + "node": ">=0.10.0" } }, - "node_modules/micromark-util-combine-extensions": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", - "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", "dependencies": { - "micromark-util-chunked": "^2.0.0", - "micromark-util-types": "^2.0.0" + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" } }, - "node_modules/micromark-util-decode-numeric-character-reference": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", - "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" ], "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" + "engines": { + "node": ">=12.20.0" } }, - "node_modules/micromark-util-decode-string": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", - "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], + "node_modules/ofetch": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", + "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", "license": "MIT", "dependencies": { - "decode-named-character-reference": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-symbol": "^2.0.0" + "destr": "^2.0.5", + "node-fetch-native": "^1.6.7", + "ufo": "^1.6.1" } }, - "node_modules/micromark-util-encode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", - "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], + "node_modules/ohash": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz", + "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==", "license": "MIT" }, - "node_modules/micromark-util-html-tag-name": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", - "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], + "node_modules/oniguruma-parser": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", "license": "MIT" }, - "node_modules/micromark-util-normalize-identifier": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", - "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], + "node_modules/oniguruma-to-es": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", "license": "MIT", "dependencies": { - "micromark-util-symbol": "^2.0.0" + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" } }, - "node_modules/micromark-util-resolve-all": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", - "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], + "node_modules/p-limit": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.1.tgz", + "integrity": "sha512-0trZaiG7Y7kN/Egy9a8j47t9osC0Tch4PaIWd9yGF6bvmlk7muExRvGNYb8sXBwEKMoNKsbNN9P8EefuQekE4Q==", "license": "MIT", "dependencies": { - "micromark-util-types": "^2.0.0" + "yocto-queue": "^1.2.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", - "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], + "node_modules/p-queue": { + "version": "9.3.3", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.3.tgz", + "integrity": "sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA==", "license": "MIT", "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-subtokenize": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", - "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-types": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", - "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/neotraverse": { - "version": "0.6.18", - "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-0.6.18.tgz", - "integrity": "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==", - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/nlcst-to-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz", - "integrity": "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==", - "license": "MIT", - "dependencies": { - "@types/nlcst": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/node-fetch-native": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", - "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", - "license": "MIT" - }, - "node_modules/node-mock-http": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.4.tgz", - "integrity": "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==", - "license": "MIT" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0" - }, - "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" - } - }, - "node_modules/ofetch": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", - "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", - "license": "MIT", - "dependencies": { - "destr": "^2.0.5", - "node-fetch-native": "^1.6.7", - "ufo": "^1.6.1" - } - }, - "node_modules/ohash": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", - "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", - "license": "MIT" - }, - "node_modules/oniguruma-parser": { - "version": "0.12.1", - "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", - "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==", - "license": "MIT" - }, - "node_modules/oniguruma-to-es": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.4.tgz", - "integrity": "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==", - "license": "MIT", - "dependencies": { - "oniguruma-parser": "^0.12.1", - "regex": "^6.0.1", - "regex-recursion": "^6.0.2" - } - }, - "node_modules/p-limit": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-6.2.0.tgz", - "integrity": "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==", - "license": "MIT", - "dependencies": { - "yocto-queue": "^1.1.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-queue": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-8.1.1.tgz", - "integrity": "sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==", - "license": "MIT", - "dependencies": { - "eventemitter3": "^5.0.1", - "p-timeout": "^6.1.2" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "eventemitter3": "^5.0.4", + "p-timeout": "^7.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/p-timeout": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz", - "integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz", + "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==", "license": "MIT", "engines": { - "node": ">=14.16" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -3788,36 +3310,6 @@ "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", "license": "MIT" }, - "node_modules/parse-latin": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/parse-latin/-/parse-latin-7.0.0.tgz", - "integrity": "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==", - "license": "MIT", - "dependencies": { - "@types/nlcst": "^2.0.0", - "@types/unist": "^3.0.0", - "nlcst-to-string": "^4.0.0", - "unist-util-modify-children": "^4.0.0", - "unist-util-visit-children": "^3.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, "node_modules/piccolore": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", @@ -3831,9 +3323,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "license": "MIT", "engines": { "node": ">=12" @@ -3843,9 +3335,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "funding": [ { "type": "opencollective", @@ -3862,7 +3354,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -3879,23 +3371,19 @@ "node": ">=6" } }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "node_modules/process-ancestry": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/process-ancestry/-/process-ancestry-0.1.0.tgz", + "integrity": "sha512-tGqJW/UnclpYASFcM6Xh8D8l/BMtaQ9+CSG0vlJSJTcdMM4lDRv4c6H0Pdcsfted+bVczdYSfk2fdukg2gQkZg==", "license": "MIT", - "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - }, "engines": { - "node": ">= 6" + "node": ">=18.0.0" } }, "node_modules/property-information": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", - "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", "license": "MIT", "funding": { "type": "github", @@ -3909,9 +3397,9 @@ "license": "MIT" }, "node_modules/readdirp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", "license": "MIT", "engines": { "node": ">= 20.19.0" @@ -3945,333 +3433,174 @@ "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", "license": "MIT" }, - "node_modules/rehype": { - "version": "13.0.2", - "resolved": "https://registry.npmjs.org/rehype/-/rehype-13.0.2.tgz", - "integrity": "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==", + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "rehype-parse": "^9.0.0", - "rehype-stringify": "^10.0.0", - "unified": "^11.0.0" - }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, - "node_modules/rehype-parse": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz", - "integrity": "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==", + "node_modules/retext-smartypants": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz", + "integrity": "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==", "license": "MIT", "dependencies": { - "@types/hast": "^3.0.0", - "hast-util-from-html": "^2.0.0", - "unified": "^11.0.0" + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-visit": "^5.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" } }, - "node_modules/rehype-raw": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", - "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "node_modules/rolldown": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz", + "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==", "license": "MIT", "dependencies": { - "@types/hast": "^3.0.0", - "hast-util-raw": "^9.0.0", - "vfile": "^6.0.0" + "@oxc-project/types": "=0.147.0", + "@rolldown/pluginutils": "^1.0.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.6", + "@rolldown/binding-android-arm64": "1.2.6", + "@rolldown/binding-darwin-arm64": "1.2.6", + "@rolldown/binding-darwin-x64": "1.2.6", + "@rolldown/binding-freebsd-x64": "1.2.6", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", + "@rolldown/binding-linux-arm64-gnu": "1.2.6", + "@rolldown/binding-linux-arm64-musl": "1.2.6", + "@rolldown/binding-linux-ppc64-gnu": "1.2.6", + "@rolldown/binding-linux-s390x-gnu": "1.2.6", + "@rolldown/binding-linux-x64-gnu": "1.2.6", + "@rolldown/binding-linux-x64-musl": "1.2.6", + "@rolldown/binding-openharmony-arm64": "1.2.6", + "@rolldown/binding-win32-arm64-msvc": "1.2.6", + "@rolldown/binding-win32-x64-msvc": "1.2.6" + } + }, + "node_modules/satteri": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/satteri/-/satteri-0.10.5.tgz", + "integrity": "sha512-Ao1LKpAEa9Wdg0otgbVKViZHEq9ebdXe4DMrp3s9vQAU0HNIuHnFEuMuOcm0ZIXyV0Yzxj91NvhLpvXZJO/5ZQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.5", + "@types/hast": "^3.0.5", + "@types/mdast": "^4.0.4", + "@types/unist": "^3.0.3" + }, + "optionalDependencies": { + "@bruits/satteri-darwin-arm64": "0.10.5", + "@bruits/satteri-darwin-x64": "0.10.5", + "@bruits/satteri-linux-arm64-gnu": "0.10.5", + "@bruits/satteri-linux-arm64-musl": "0.10.5", + "@bruits/satteri-linux-x64-gnu": "0.10.5", + "@bruits/satteri-linux-x64-musl": "0.10.5", + "@bruits/satteri-wasm32-wasi": "0.10.5", + "@bruits/satteri-win32-arm64-msvc": "0.10.5", + "@bruits/satteri-win32-x64-msvc": "0.10.5" } }, - "node_modules/rehype-stringify": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", - "integrity": "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "hast-util-to-html": "^9.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" } }, - "node_modules/remark-gfm": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", - "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-gfm": "^3.0.0", - "micromark-extension-gfm": "^3.0.0", - "remark-parse": "^11.0.0", - "remark-stringify": "^11.0.0", - "unified": "^11.0.0" + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "engines": { + "node": ">=10" } }, - "node_modules/remark-parse": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", - "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", - "license": "MIT", + "node_modules/sharp": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "license": "Apache-2.0", + "optional": true, "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-rehype": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", - "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "mdast-util-to-hast": "^13.0.0", - "unified": "^11.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-smartypants": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/remark-smartypants/-/remark-smartypants-3.0.2.tgz", - "integrity": "sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA==", - "license": "MIT", - "dependencies": { - "retext": "^9.0.0", - "retext-smartypants": "^6.0.0", - "unified": "^11.0.4", - "unist-util-visit": "^5.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/remark-stringify": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", - "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-to-markdown": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/retext": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/retext/-/retext-9.0.0.tgz", - "integrity": "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==", - "license": "MIT", - "dependencies": { - "@types/nlcst": "^2.0.0", - "retext-latin": "^4.0.0", - "retext-stringify": "^4.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/retext-latin": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/retext-latin/-/retext-latin-4.0.0.tgz", - "integrity": "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==", - "license": "MIT", - "dependencies": { - "@types/nlcst": "^2.0.0", - "parse-latin": "^7.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/retext-smartypants": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz", - "integrity": "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==", - "license": "MIT", - "dependencies": { - "@types/nlcst": "^2.0.0", - "nlcst-to-string": "^4.0.0", - "unist-util-visit": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/retext-stringify": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/retext-stringify/-/retext-stringify-4.0.0.tgz", - "integrity": "sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==", - "license": "MIT", - "dependencies": { - "@types/nlcst": "^2.0.0", - "nlcst-to-string": "^4.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", - "fsevents": "~2.3.2" - } - }, - "node_modules/sax": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", - "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=11.0.0" - } - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.2", - "semver": "^7.7.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/shiki": { - "version": "3.22.0", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.22.0.tgz", - "integrity": "sha512-LBnhsoYEe0Eou4e1VgJACes+O6S6QC0w71fCSp5Oya79inkwkm15gQ1UF6VtQ8j/taMDh79hAB49WUk8ALQW3g==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.4.3.tgz", + "integrity": "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==", "license": "MIT", "dependencies": { - "@shikijs/core": "3.22.0", - "@shikijs/engine-javascript": "3.22.0", - "@shikijs/engine-oniguruma": "3.22.0", - "@shikijs/langs": "3.22.0", - "@shikijs/themes": "3.22.0", - "@shikijs/types": "3.22.0", + "@shikijs/core": "4.4.3", + "@shikijs/engine-javascript": "4.4.3", + "@shikijs/engine-oniguruma": "4.4.3", + "@shikijs/langs": "4.4.3", + "@shikijs/themes": "4.4.3", + "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" } }, "node_modules/sisteransi": { @@ -4306,9 +3635,9 @@ "license": "MIT" }, "node_modules/smol-toml": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz", - "integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", + "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==", "license": "BSD-3-Clause", "engines": { "node": ">= 18" @@ -4342,23 +3671,6 @@ "integrity": "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==", "license": "MIT" }, - "node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -4373,34 +3685,19 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/svgo": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.0.tgz", - "integrity": "sha512-VvrHQ+9uniE+Mvx3+C9IEe/lWasXCU0nXMY2kZeLrHNICuRiC8uMPyM14UEaMOFA5mhyQqEkB02VoQ16n3DLaw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.1.0.tgz", + "integrity": "sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q==", "license": "MIT", "dependencies": { "commander": "^11.1.0", - "css-select": "^5.1.0", + "css-select": "^6.0.0", "css-tree": "^3.0.1", - "css-what": "^6.1.0", + "css-what": "^7.0.0", "csso": "^5.0.5", "picocolors": "^1.1.1", - "sax": "^1.4.1" + "sax": "1.6.1" }, "bin": { "svgo": "bin/svgo.js" @@ -4419,23 +3716,32 @@ "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", "license": "MIT" }, + "node_modules/tinyclip": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.15.tgz", + "integrity": "sha512-uo33abH+Ays0xYaDysoBt494Hb3hsEczMpcC0MwFl773pazORx4fmvKhclhR1wonUbB6vvpRsvVMwnhfqeMc+A==", + "license": "MIT", + "engines": { + "node": "^16.14.0 || >= 17.3.0" + } + }, "node_modules/tinyexec": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", "license": "MIT", "engines": { "node": ">=18" } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -4464,26 +3770,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/tsconfck": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", - "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", - "license": "MIT", - "bin": { - "tsconfck": "bin/tsconfck.js" - }, - "engines": { - "node": "^18 || >=20" - }, - "peerDependencies": { - "typescript": "^5.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -4491,36 +3777,10 @@ "license": "0BSD", "optional": true }, - "node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "license": "Apache-2.0", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/ufo": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", - "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", "license": "MIT" }, "node_modules/ultrahtml": { @@ -4535,6 +3795,15 @@ "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", "license": "MIT" }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -4561,28 +3830,14 @@ } }, "node_modules/unifont": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/unifont/-/unifont-0.7.4.tgz", - "integrity": "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg==", + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/unifont/-/unifont-0.7.5.tgz", + "integrity": "sha512-ULe/Cs+ZIsq+dcFofNkhqielCrUJnb5mr+Yc4EBM2VlL+6OZR6+cjtI2mT1bJvRBrVncqHAbLURxmPLcCXzWMg==", "license": "MIT", "dependencies": { "css-tree": "^3.1.0", - "ofetch": "^1.5.1", - "ohash": "^2.0.11" - } - }, - "node_modules/unist-util-find-after": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", - "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "ohash": "^2.0.11", + "undici": "^8.0.0" } }, "node_modules/unist-util-is": { @@ -4598,20 +3853,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/unist-util-modify-children": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-modify-children/-/unist-util-modify-children-4.0.0.tgz", - "integrity": "sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "array-iterate": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/unist-util-position": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", @@ -4625,20 +3866,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/unist-util-remove-position": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", - "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-visit": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/unist-util-stringify-position": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", @@ -4646,791 +3873,259 @@ "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", - "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-children": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/unist-util-visit-children/-/unist-util-visit-children-3.0.0.tgz", - "integrity": "sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-parents": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", - "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unstorage": { - "version": "1.17.4", - "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.4.tgz", - "integrity": "sha512-fHK0yNg38tBiJKp/Vgsq4j0JEsCmgqH58HAn707S7zGkArbZsVr/CwINoi+nh3h98BRCwKvx1K3Xg9u3VV83sw==", - "license": "MIT", - "dependencies": { - "anymatch": "^3.1.3", - "chokidar": "^5.0.0", - "destr": "^2.0.5", - "h3": "^1.15.5", - "lru-cache": "^11.2.0", - "node-fetch-native": "^1.6.7", - "ofetch": "^1.5.1", - "ufo": "^1.6.3" - }, - "peerDependencies": { - "@azure/app-configuration": "^1.8.0", - "@azure/cosmos": "^4.2.0", - "@azure/data-tables": "^13.3.0", - "@azure/identity": "^4.6.0", - "@azure/keyvault-secrets": "^4.9.0", - "@azure/storage-blob": "^12.26.0", - "@capacitor/preferences": "^6 || ^7 || ^8", - "@deno/kv": ">=0.9.0", - "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", - "@planetscale/database": "^1.19.0", - "@upstash/redis": "^1.34.3", - "@vercel/blob": ">=0.27.1", - "@vercel/functions": "^2.2.12 || ^3.0.0", - "@vercel/kv": "^1 || ^2 || ^3", - "aws4fetch": "^1.0.20", - "db0": ">=0.2.1", - "idb-keyval": "^6.2.1", - "ioredis": "^5.4.2", - "uploadthing": "^7.4.4" - }, - "peerDependenciesMeta": { - "@azure/app-configuration": { - "optional": true - }, - "@azure/cosmos": { - "optional": true - }, - "@azure/data-tables": { - "optional": true - }, - "@azure/identity": { - "optional": true - }, - "@azure/keyvault-secrets": { - "optional": true - }, - "@azure/storage-blob": { - "optional": true - }, - "@capacitor/preferences": { - "optional": true - }, - "@deno/kv": { - "optional": true - }, - "@netlify/blobs": { - "optional": true - }, - "@planetscale/database": { - "optional": true - }, - "@upstash/redis": { - "optional": true - }, - "@vercel/blob": { - "optional": true - }, - "@vercel/functions": { - "optional": true - }, - "@vercel/kv": { - "optional": true - }, - "aws4fetch": { - "optional": true - }, - "db0": { - "optional": true - }, - "idb-keyval": { - "optional": true - }, - "ioredis": { - "optional": true - }, - "uploadthing": { - "optional": true - } - } - }, - "node_modules/vfile": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", - "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-location": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", - "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-message": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", - "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", - "license": "MIT", - "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/vite/node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/vite/node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "hasInstallScript": true, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/vitefu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", - "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", + "node_modules/unstorage": { + "version": "1.17.5", + "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.5.tgz", + "integrity": "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==", "license": "MIT", - "workspaces": [ - "tests/deps/*", - "tests/projects/*", - "tests/projects/workspace/packages/*" - ], + "dependencies": { + "anymatch": "^3.1.3", + "chokidar": "^5.0.0", + "destr": "^2.0.5", + "h3": "^1.15.10", + "lru-cache": "^11.2.7", + "node-fetch-native": "^1.6.7", + "ofetch": "^1.5.1", + "ufo": "^1.6.3" + }, "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + "@azure/app-configuration": "^1.8.0", + "@azure/cosmos": "^4.2.0", + "@azure/data-tables": "^13.3.0", + "@azure/identity": "^4.6.0", + "@azure/keyvault-secrets": "^4.9.0", + "@azure/storage-blob": "^12.26.0", + "@capacitor/preferences": "^6 || ^7 || ^8", + "@deno/kv": ">=0.9.0", + "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", + "@planetscale/database": "^1.19.0", + "@upstash/redis": "^1.34.3", + "@vercel/blob": ">=0.27.1", + "@vercel/functions": "^2.2.12 || ^3.0.0", + "@vercel/kv": "^1 || ^2 || ^3", + "aws4fetch": "^1.0.20", + "db0": ">=0.2.1", + "idb-keyval": "^6.2.1", + "ioredis": "^5.4.2", + "uploadthing": "^7.4.4" }, "peerDependenciesMeta": { - "vite": { + "@azure/app-configuration": { + "optional": true + }, + "@azure/cosmos": { + "optional": true + }, + "@azure/data-tables": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-secrets": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@capacitor/preferences": { + "optional": true + }, + "@deno/kv": { + "optional": true + }, + "@netlify/blobs": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/blob": { + "optional": true + }, + "@vercel/functions": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "aws4fetch": { + "optional": true + }, + "db0": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "uploadthing": { "optional": true } } }, - "node_modules/web-namespaces": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", - "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/which-pm-runs": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/which-pm-runs/-/which-pm-runs-1.1.0.tgz", - "integrity": "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==", + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", "license": "MIT", - "engines": { - "node": ">=4" + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/widest-line": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", - "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", "license": "MIT", "dependencies": { - "string-width": "^7.0.0" + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" }, "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } } }, - "node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "peerDependenciesMeta": { + "vite": { + "optional": true + } } }, "node_modules/xxhash-wasm": { @@ -5440,12 +4135,12 @@ "license": "MIT" }, "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", "license": "ISC", "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/yocto-queue": { @@ -5460,33 +4155,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yocto-spinner": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-0.2.3.tgz", - "integrity": "sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ==", - "license": "MIT", - "dependencies": { - "yoctocolors": "^2.1.1" - }, - "engines": { - "node": ">=18.19" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/yoctocolors": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", - "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", @@ -5496,24 +4164,6 @@ "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/zod-to-json-schema": { - "version": "3.25.1", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", - "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.25 || ^4" - } - }, - "node_modules/zod-to-ts": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/zod-to-ts/-/zod-to-ts-1.2.0.tgz", - "integrity": "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA==", - "peerDependencies": { - "typescript": "^4.9.4 || ^5.0.2", - "zod": "^3" - } - }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/website/package.json b/website/package.json index b23449e..0152550 100644 --- a/website/package.json +++ b/website/package.json @@ -1,15 +1,24 @@ { "name": "keyjawn-site", + "private": true, "type": "module", "version": "0.0.1", + "engines": { + "node": ">=22.19.0" + }, + "allowScripts": { + "esbuild@0.28.2": true, + "fsevents": false + }, "scripts": { "dev": "astro dev", "build": "astro build", + "check": "astro build", "preview": "astro preview", "astro": "astro" }, "dependencies": { "@astrojs/sitemap": "^3.7.0", - "astro": "^5.17.1" + "astro": "^7.2.9" } } diff --git a/website/public/favicon.ico b/website/public/favicon.ico deleted file mode 100644 index 7f48a94..0000000 Binary files a/website/public/favicon.ico and /dev/null differ diff --git a/website/public/llms.txt b/website/public/llms.txt index 9d847ad..8cca6c2 100644 --- a/website/public/llms.txt +++ b/website/public/llms.txt @@ -1,40 +1,45 @@ # KeyJawn -> Custom Android keyboard for terminal and LLM CLI usage +> Terminal keyboard tools for Android and iOS -KeyJawn is a mobile keyboard that adds terminal keys (Esc, Tab, Ctrl, arrow keys), voice input, SCP image upload, and slash command shortcuts to a standard QWERTY layout. Built for people who SSH into servers from their phones to use AI CLI tools like Claude Code and OpenClaw. +KeyJawn helps people work with remote terminals from a phone. Android and iOS have different features and distribution paths. -## Versions +## iOS beta -- KeyJawn Full ($4 lifetime): Terminal keys, voice input, SCP upload, clipboard history, slash commands, swipe gestures, per-app autocorrect toggle. No subscriptions, no ads, no tracking. -- KeyJawn Lite (free): Terminal keys, voice input, number row, alt character popups. No network permissions required. +- The public TestFlight invitation is not accepting new testers as of August 28, 2026. +- A revised App Store submission has not been sent. +- The app includes a built-in remote SSH terminal. Commands run on the configured server, not on the phone. +- The keyboard extension enters text and control sequences into the active app. The receiving app decides how to interpret them. +- Basic keyboard input does not need Full Access. +- Full Access is optional. KeyJawn uses it for copied-image upload, shared keyboard settings, user-created shortcuts, or clipboard history. +- Image upload reads one copied image from the pasteboard, prepares it in memory, uploads it with SFTP to a host configured for SSH key authentication, and enters the remote path. Password authentication remains available for terminal connections. +- The iOS app does not request Photos or Files access. The copied-image upload feature does not browse local files or list, read, or download remote files. +- Voice input is available in the built-in terminal, not in the keyboard extension. -## Key features +## Android -- Dedicated terminal key row: Esc, Tab, Ctrl (three-state toggle), arrow keys -- Voice-to-text with streaming transcription -- SCP image upload: pick a photo, upload to server, path typed at cursor -- Slash command quick-insert for LLM CLI tools -- Number row with long-press shifted symbols -- Long-press alt characters (accented letters, punctuation variants) -- Clipboard history (last 10 items) -- Per-app autocorrect toggle (off by default for terminals) -- Swipe gestures: left=delete word, right=space, up=symbols, down=letters +- Android APK releases: https://github.com/jamditis/keyjawn/releases +- KeyJawn Lite has active internal and closed Google Play test tracks. Google Play production is inactive. +- The free APK includes terminal keys, voice input, clipboard history, built-in slash commands, swipe gestures, and per-app autocorrect. +- The $4 Android full APK adds image upload, SSH host management, themes, clipboard pins, and custom command sets. +- The Android purchase does not unlock an iOS product. -## Requirements +## Privacy boundary -- Android 8.0+ (API 26) -- Sideload via APK (not on Play Store) +- KeyJawn has no developer analytics, ads, or tracking. +- User-started SSH, SFTP, and speech features send only the data needed for the requested operation to the selected server or platform speech service. +- SSH credentials stay in platform-protected device storage. ## Links - Homepage: https://keyjawn.amditis.tech - Features: https://keyjawn.amditis.tech/features - Manual: https://keyjawn.amditis.tech/manual -- Pricing: https://keyjawn.amditis.tech/pricing +- Pricing and availability: https://keyjawn.amditis.tech/pricing +- Privacy: https://keyjawn.amditis.tech/privacy - Support: https://keyjawn.amditis.tech/support -- GitHub: https://github.com/jamditis/keyjawn -- Download: https://github.com/jamditis/keyjawn/releases +- Changelog: https://keyjawn.amditis.tech/changelog +- Source: https://github.com/jamditis/keyjawn - Blog: https://keyjawn.amditis.tech/blog/why-i-built-keyjawn ## License diff --git a/website/public/screenshots/ios-screenshots/ios-add-host.png b/website/public/screenshots/ios-screenshots/ios-add-host.png deleted file mode 100644 index 6368f08..0000000 Binary files a/website/public/screenshots/ios-screenshots/ios-add-host.png and /dev/null differ diff --git a/website/public/screenshots/ios-screenshots/ios-connections.png b/website/public/screenshots/ios-screenshots/ios-connections.png deleted file mode 100644 index 4aeeea7..0000000 Binary files a/website/public/screenshots/ios-screenshots/ios-connections.png and /dev/null differ diff --git a/website/public/screenshots/ios-screenshots/ios-settings.png b/website/public/screenshots/ios-screenshots/ios-settings.png deleted file mode 100644 index 7d1109f..0000000 Binary files a/website/public/screenshots/ios-screenshots/ios-settings.png and /dev/null differ diff --git a/website/public/screenshots/ios-screenshots/ios-slash-commands.png b/website/public/screenshots/ios-screenshots/ios-slash-commands.png deleted file mode 100644 index e2bed48..0000000 Binary files a/website/public/screenshots/ios-screenshots/ios-slash-commands.png and /dev/null differ diff --git a/website/public/screenshots/ios-screenshots/ios-ssh.png b/website/public/screenshots/ios-screenshots/ios-ssh.png deleted file mode 100644 index 013dfe9..0000000 Binary files a/website/public/screenshots/ios-screenshots/ios-ssh.png and /dev/null differ diff --git a/website/src/components/Footer.astro b/website/src/components/Footer.astro index ab5a313..69f87d7 100644 --- a/website/src/components/Footer.astro +++ b/website/src/components/Footer.astro @@ -20,7 +20,8 @@ const year = new Date().getFullYear();
diff --git a/website/src/components/PhoneMockup.astro b/website/src/components/PhoneMockup.astro index 7eda77b..d4565c3 100644 --- a/website/src/components/PhoneMockup.astro +++ b/website/src/components/PhoneMockup.astro @@ -3,7 +3,7 @@
- + @@ -17,10 +17,10 @@ - jamditis@pi:~$ - claude - Claude Code v1.2.0 - Type your message... + remote SSH session + user@remote:~$ + commands run on the server + KeyJawn shows returned output > @@ -156,7 +156,7 @@ - + @@ -200,7 +200,7 @@ const LINE_START_Y = 126; const LINE_HEIGHT = 14; - const INITIAL_TEXT = 'chode --dangerously-skip-permissions'; + const INITIAL_TEXT = "printf 'hello from remote'"; let buffer = INITIAL_TEXT; let shifted = false; let cursorPos = INITIAL_TEXT.length; diff --git a/website/src/layouts/Base.astro b/website/src/layouts/Base.astro index 091cc4e..5f83977 100644 --- a/website/src/layouts/Base.astro +++ b/website/src/layouts/Base.astro @@ -8,6 +8,7 @@ interface Props { description: string; ogType?: string; ogImage?: string; + ogImageAlt?: string; articleDate?: string; articleAuthor?: string; jsonLd?: object; @@ -18,6 +19,7 @@ const { description, ogType = 'website', ogImage = '/og-image.png', + ogImageAlt = 'KeyJawn terminal keyboard with Esc, Tab, Ctrl, arrow, microphone, and remote upload keys', articleDate, articleAuthor, jsonLd, @@ -28,13 +30,6 @@ const ogImageURL = new URL(ogImage, Astro.site); --- - @@ -50,8 +45,10 @@ const ogImageURL = new URL(ogImage, Astro.site); + + {articleDate && } {articleAuthor && } @@ -60,10 +57,11 @@ const ogImageURL = new URL(ogImage, Astro.site); + + - {jsonLd && ( diff --git a/website/src/pages/about.astro b/website/src/pages/about.astro index 4b83f55..cca0728 100644 --- a/website/src/pages/about.astro +++ b/website/src/pages/about.astro @@ -33,12 +33,14 @@ import FeatureCard from '../components/FeatureCard.astro'; diff --git a/website/src/pages/blog/why-i-built-keyjawn.md b/website/src/pages/blog/why-i-built-keyjawn.md index d2adc3d..b8967f1 100644 --- a/website/src/pages/blog/why-i-built-keyjawn.md +++ b/website/src/pages/blog/why-i-built-keyjawn.md @@ -6,6 +6,8 @@ date: 2026-02-15 author: Joe Amditis --- +> Platform note: This February 2026 post describes KeyJawn for Android. The iOS beta has different limits. It includes a keyboard extension and a built-in remote SSH terminal. Commands in that terminal run on the configured server, not on the phone. As of August 28, 2026, the public beta invitation is not accepting new testers. A revised App Store submission has not been sent. + More people use the terminal now than at any point in the last decade, and most of them aren't sysadmins. Claude Code, OpenClaw, Gemini CLI, Codex -- these tools live in a shell. If you want to use them, you need a terminal. And a growing number of people are accessing that terminal from their phones, SSHing into a Raspberry Pi or a Mac Mini from the couch, a waiting room, or the train. @@ -20,7 +22,7 @@ The existing options fall into two categories, and none of them were designed fo **SSH clients** like [Termius](https://termius.com/), [ConnectBot](https://connectbot.org/), and [JuiceSSH](https://juicessh.com/) include their own terminal key toolbars. They work well inside their own apps, but the keys only exist inside those apps. If you use Cockpit in a browser, or Termux, or any other terminal tool, those toolbars don't help you. Termux has its own configurable [extra keys row](https://wiki.termux.com/wiki/Touch_Keyboard), which is useful if Termux is your only terminal app -- but it doesn't carry over to other apps either. -KeyJawn is a system keyboard. It replaces Gboard, not your SSH client. You install it, enable it in Android settings, and the terminal keys work in every app. But the real difference is what it was built for: talking to AI agents via the CLI, where most of your input is natural language and the tools you use have slash commands, not vim keybindings. +KeyJawn is an Android system keyboard. It replaces Gboard, not your SSH client. You install it, enable it in Android settings, and use it in apps that accept third-party keyboards. Terminal-key handling depends on the receiving app. The real difference is what it was built for: talking to AI agents through the CLI, where most input is natural language and the tools use slash commands, not vim keybindings. ## The actual problem @@ -44,7 +46,7 @@ The keyboard has a dedicated terminal row above the QWERTY layout: `Esc`, `Tab`, **SCP image upload.** I SSH into Raspberry Pis from my phone constantly. When I need to share a screenshot with Claude Code -- an error message, a UI bug, a terminal output -- the normal process is: save the image, open a file manager, find an SCP client, configure the connection, transfer the file, switch back to the terminal, type the path. KeyJawn does it in one tap. Pick the photo, it SCPs to your server, and the remote file path gets typed at your cursor. -**A system keyboard that works everywhere.** Termius has terminal keys, but they're inside the Termius app. Browser extensions add key rows, but they float above the page and break when you switch apps or the browser redraws. Hacker's Keyboard is a system keyboard, but it's a full desktop layout crammed onto a phone screen. KeyJawn installs as an Android input method -- the terminal keys are part of the keyboard itself. They work in Termux, in Cockpit through Chrome, in JuiceSSH, in any app. You set it once and forget about it. +**An Android system keyboard.** Terminal keys in terminal apps are often part of that app. KeyJawn installs as an Android input method, so its terminal row travels with the keyboard. The receiving app still decides how to handle each key event, and browser terminals can differ from native terminal apps. **Customizable for how you actually work.** I built this for my own workflow: SSHing into a Pi from a couch to run Claude Code sessions. The default key layout reflects that -- the keys I reach for most are the ones I put in the terminal row. Slash command sets are configurable per tool (Claude Code, OpenClaw, Gemini CLI). Autocorrect toggles per app so you can leave it off for terminals and on for Slack. SSH hosts are saved so you can switch between machines without retyping credentials. The point is to fit the keyboard to your workflow instead of working around the keyboard. @@ -66,6 +68,6 @@ The code is MIT-licensed and [on GitHub](https://github.com/jamditis/keyjawn). B ## Try it -[Download the APK](https://github.com/jamditis/keyjawn/releases) and see if it fits your workflow. If something doesn't work, [file an issue](https://github.com/jamditis/keyjawn/issues). PRs are open. +[Download the Android APK](https://github.com/jamditis/keyjawn/releases) and see if it fits your workflow. If something doesn't work, [file an issue](https://github.com/jamditis/keyjawn/issues). PRs are open. Your phone keyboard shouldn't be the reason you can't use a terminal. diff --git a/website/src/pages/changelog.astro b/website/src/pages/changelog.astro index c37b40c..e447e82 100644 --- a/website/src/pages/changelog.astro +++ b/website/src/pages/changelog.astro @@ -4,17 +4,28 @@ import Base from '../layouts/Base.astro';

Changelog

-

Release history. Each version links to the full release notes on GitHub.

+

Published Android releases and the current iOS distribution status.

+
+

iOS status

+

As of August 28, 2026, the public TestFlight invitation is not accepting new testers. A revised App Store submission has not been sent.

+
+ +

Android releases

+ +
+

Android distribution status

+

KeyJawn Lite has active internal and closed Google Play test tracks. Open testing and production are inactive. Public Android APKs remain available through GitHub releases.

+
@@ -22,7 +33,7 @@ import Base from '../layouts/Base.astro';
    -
  • Google Play Store publishing -- lite version now available on Google Play via internal testing. Store listing, content rating, and data safety forms complete.
  • +
  • Google Play internal testing -- lite version 1.3.0 was uploaded to the internal test track. Store listing, content rating, and data safety forms complete.
  • CI release improvements -- lite AAB now included in GitHub release assets for easier distribution. Gradle Play Publisher downgraded to 3.13.0 for Gradle 8.13 compatibility.
Full release notes @@ -88,10 +99,10 @@ import Base from '../layouts/Base.astro';
  • Menu panel -- overlay with SCP upload, theme picker, settings access, and quick toggles for haptic feedback, autocorrect, and tooltips.
  • -
  • Clipboard pinning -- pin frequently used items to persist across sessions. Up to 15 pins, 30 history items. Full version only.
  • +
  • Clipboard pinning -- pin frequently used items to persist across sessions. Up to 15 pins, 30 history items. Android full APK only.
  • Inline panels -- clipboard and menu now render as overlays within the keyboard instead of floating popups.
  • -
  • Tooltip system -- inline hints replace toast messages. Auto-dismiss after 1.5s. Configurable in full version.
  • -
  • Free/full split -- voice input, clipboard history, slash commands, swipe gestures, and per-app autocorrect now free. Full version adds SCP, themes, pinning, custom commands.
  • +
  • Tooltip system -- inline hints replace toast messages. Auto-dismiss after 1.5s. Configurable in the Android full APK.
  • +
  • Free/full split -- voice input, clipboard history, slash commands, swipe gestures, and per-app autocorrect now free. The Android full APK adds SCP, themes, pinning, and custom commands.
Full release notes @@ -115,6 +126,24 @@ import Base from '../layouts/Base.astro'; max-width: 720px; } + .platform-status { + padding: 0 0 var(--space-lg); + border-bottom: 1px solid var(--border); + } + + .platform-status h2, + .platform-heading { + font-size: 1.4rem; + } + + .platform-status p { + line-height: 1.7; + } + + .platform-heading { + margin: var(--space-lg) 0 0; + } + .release { padding: var(--space-lg) 0; border-bottom: 1px solid var(--border); diff --git a/website/src/pages/features.astro b/website/src/pages/features.astro index d586b1c..d5987fc 100644 --- a/website/src/pages/features.astro +++ b/website/src/pages/features.astro @@ -6,12 +6,57 @@ import FeatureCard from '../components/FeatureCard.astro';

Features

-

Everything the keyboard does. The free version includes terminal keys, voice input, clipboard history, slash commands, swipe gestures, per-app autocorrect, key press preview, and a configurable quick key. The full version ($4) adds SCP upload, multi-host SSH, keyboard themes, customizable extra row keys, a menu panel, clipboard pinning, and custom slash commands.

+

Android and iOS use different keyboard APIs. The sections below state which behavior belongs to each platform.

+
+
+ +
+
+

iOS app and keyboard

+
+ + + + + + +
+
+
+ +
+
+

Android keyboard

+

The detailed feature sections below describe the Android keyboard. The free APK includes terminal keys, voice input, clipboard history, slash commands, action swipes, per-app autocorrect, key press preview, and a configurable quick key. The $4 Android full APK adds SCP upload, multi-host SSH, themes, customizable extra-row keys, a menu panel, clipboard pinning, and custom slash commands.

@@ -23,7 +68,7 @@ import FeatureCard from '../components/FeatureCard.astro';

A dedicated row above the QWERTY layout with the keys terminals need: , , , and arrow keys .

Always visible. No toggling layers or long-pressing to find them. uses a three-state toggle: tap once to arm it for one keypress, long-press to lock it on, tap again to turn it off. Hold arrow keys to repeat.

Each key in the row has a distinct background color so you can tell them apart at a glance. Ctrl changes color when armed or locked.

-

In the full version, the three left slots (Esc, Tab, Ctrl) are customizable. Long-press to swap them for Home, End, PgUp, PgDn, Insert, Delete, or text characters like |, ~, `, \. Also configurable in Settings.

+

In the Android full APK, the three left slots (Esc, Tab, Ctrl) are customizable. Long-press to swap them for Home, End, PgUp, PgDn, Insert, Delete, or text characters like |, ~, `, \. You can also change them in Settings.

@@ -44,7 +89,7 @@ import FeatureCard from '../components/FeatureCard.astro';

Voice input

Tap the mic in the extra row. A waveform animation shows audio level while you speak. Text appears at your cursor as the speech recognizer processes it -- streaming partial results, not waiting for you to finish.

When 90% of what you type into an AI agent is natural language, speaking is faster than typing on a phone keyboard. Tap stop or the mic again to finish.

-

Requires microphone permission. Included in both free and full versions.

+

Requires microphone permission. Included in both Android APKs.

@@ -68,15 +113,15 @@ import FeatureCard from '../components/FeatureCard.astro';
-

SCP upload

-

Configure your SSH hosts in settings. Tap the upload button in the extra row. Pick a photo from your gallery. KeyJawn SCPs it to your server and types the remote file path at your cursor.

+

Android SCP upload

+

Configure your SSH hosts in Settings. Tap the upload button in the extra row, then select an image from your Android gallery. KeyJawn sends it to your server and types the remote file path at your cursor.

Supports multiple SSH hosts with per-host upload directories. Switch between servers without reconfiguring. All credentials encrypted with AES-256 via AndroidX EncryptedSharedPreferences. Host key verification uses trust-on-first-use and rejects changed keys.

-

Full version only.

+

Android full APK only.

- # you tap upload, pick a photo
+ # select an image in Android
scp photo_001.jpg pi@server:~/uploads/
# path typed at cursor:
~/uploads/photo_001.jpg_ @@ -92,8 +137,8 @@ import FeatureCard from '../components/FeatureCard.astro';

Clipboard history and pinning

Tap the clipboard button in the extra row to see your recent clipboard history (up to 30 items). Tap any entry to paste it at your cursor.

-

The full version adds pinning -- pin up to 15 items that persist across sessions and stay at the top of the list. Pinned items are highlighted so you can tell them apart from regular history.

-

Clipboard history is free. Pinning is full version only.

+

The Android full APK adds pinning. You can pin up to 15 items that persist across sessions and stay at the top of the list. Pinned items are highlighted.

+

Clipboard history is in both Android APKs. Pinning is in the Android full APK.

@@ -111,13 +156,13 @@ import FeatureCard from '../components/FeatureCard.astro';

Slash commands

On the symbols layer, the / key opens a command picker instead of typing a literal slash. Select a command to insert it. Dismiss without selecting to type / normally.

-

Built-in sets cover Claude Code, OpenClaw, and Gemini CLI. Recently used commands float to the top. The full version lets you create custom command sets -- add commands for your own tools, group them by project, toggle sets on and off.

-

Built-in sets are free. Custom sets are full version only.

+

Built-in sets group common slash commands by tool. Recently used commands move to the top. The Android full APK lets you add commands, group them in custom sets, and turn sets on or off.

+

Built-in sets are in both Android APKs. Custom sets are in the Android full APK.

- claude-code + command-set built-in
/help
@@ -173,7 +218,7 @@ import FeatureCard from '../components/FeatureCard.astro';

Keyboard themes

Four color themes: Dark (the default), Light for outdoor use, OLED black for true-black displays, and Terminal green for the classic look.

Themes apply to every element -- keys, backgrounds, hints, accent colors, and the extra row. Pick one in the menu panel.

-

Full version only. Free version uses the Dark theme.

+

Android full APK only. The Android free APK uses the Dark theme.

@@ -205,7 +250,7 @@ import FeatureCard from '../components/FeatureCard.astro';

Menu panel

Tap the gear icon in the extra row to open an inline settings overlay. Switch themes, adjust bottom padding, toggle haptic feedback, toggle autocorrect, toggle tooltips, and trigger SCP upload -- all without leaving the keyboard.

The panel overlays the keyboard rather than replacing it. Tap a setting to change it, or dismiss to go back to typing.

-

Full version only.

+

Android full APK only.

@@ -161,18 +161,19 @@ import KeyCap from '../components/KeyCap.astro';
-

Free version includes terminal keys, voice input, clipboard history, slash commands, swipe gestures, and key press preview. $4 one-time purchase adds SCP upload, keyboard themes, customizable extra row keys, menu panel, clipboard pinning, and custom slash commands.

+

Android has free and $4 full APKs. The iOS beta includes a keyboard extension and a built-in remote SSH terminal. Basic iOS typing and built-in shortcuts work without Full Access. Full Access is optional for remote image upload, shared settings, user-created shortcuts, or clipboard history.

No subscriptions. No ads. No tracking. Not in any version, ever. See what's included.

- iOS beta is live on TestFlight. App Store launch coming soon. + As of August 28, 2026, the public iOS beta invitation is not accepting new testers. A revised App Store submission has not been sent.

-

* Both versions are open source. If you can build the full APK from source, it's yours — no purchase needed. The $4 is for people who'd rather not.

+

* The KeyJawn source is open. The $4 purchase is for a prepared Android full APK and does not unlock the iOS beta.

diff --git a/website/src/pages/manual.astro b/website/src/pages/manual.astro index 0171250..9294522 100644 --- a/website/src/pages/manual.astro +++ b/website/src/pages/manual.astro @@ -6,7 +6,7 @@ import TerminalBlock from '../components/TerminalBlock.astro';
@@ -16,6 +16,9 @@ import TerminalBlock from '../components/TerminalBlock.astro';

Contents

  • Getting started
  • +
  • iOS remote SSH boundary
  • +
  • iOS keyboard and Full Access
  • +
  • iOS copied-image upload
  • Basic typing
  • Shift and caps lock
  • Terminal keys
  • @@ -26,7 +29,7 @@ import TerminalBlock from '../components/TerminalBlock.astro';
  • Key preview
  • Voice input
  • Clipboard history
  • -
  • SCP upload
  • +
  • Android SCP upload
  • Slash commands
  • Swipe gestures
  • Themes
  • @@ -60,16 +63,52 @@ import TerminalBlock from '../components/TerminalBlock.astro';

    On some devices, you can switch keyboards by long-pressing the spacebar in any text field, or by tapping the keyboard icon in the navigation bar.

    +

    KeyJawn Lite also has internal and closed Google Play test tracks. Google Play production is not active.

    -

    iOS (beta)

    -

    The iOS version is a standalone app — not just a keyboard extension. It includes a built-in SSH terminal so you can connect directly to your servers without another app.

    +

    iOS beta

    +

    The iOS version includes a keyboard extension and a built-in remote SSH terminal.

    +

    As of August 28, 2026, the public beta invitation is not accepting new testers. A revised App Store submission has not been sent.

      -
    1. Install via TestFlight (beta) or the App Store when it launches.
    2. -
    3. Open KeyJawn, tap + to add an SSH host (hostname, port, username).
    4. -
    5. Tap the host to connect. A terminal opens with the KeyJawn keyboard attached.
    6. -
    7. To use the keyboard in other apps: go to Settings > General > Keyboard > Keyboards > Add new keyboard, then select KeyJawn Keyboard.
    8. +
    9. After you install an authorized beta or release, open KeyJawn and tap + to add an SSH host (hostname, port, username).
    10. +
    11. Set up the SSH public key in Settings > SSH keys, or select password authentication for the host.
    12. +
    13. Tap the host. Review and save its fingerprint on the first connection. The remote terminal opens after the host is trusted and authentication succeeds.
    -

    The iOS keyboard extension covers Esc, Tab, Ctrl, arrows, and slash commands. SSH terminal features (voice input, host management) live in the main app.

    +

    This page does not claim that an App Store release is live.

    +
+ +
+

iOS remote SSH boundary

+

KeyJawn does not run shell commands on your iPhone or iPad. The built-in terminal sends input through SSH to the remote server that you configure. That server runs the commands, and KeyJawn displays the returned network output.

+

KeyJawn does not include a local shell, local file browser, document picker, or file provider. It cannot browse files on your iPhone or iPad.

+
+ +
+

iOS keyboard and Full Access

+
    +
  1. Open Settings > General > Keyboard > Keyboards.
  2. +
  3. Tap Add new keyboard and choose KeyJawn Keyboard.
  4. +
  5. Basic typing and built-in shortcuts work without Full Access.
  6. +
  7. Turn on Full Access only if you want the keyboard extension to upload a copied image or read shared settings, user-created shortcuts, and clipboard history.
  8. +
+

Full Access is optional. KeyJawn cannot grant it. The built-in remote SSH terminal in the main app does not need keyboard Full Access.

+

The extension inserts text through the field that has focus. Slash shortcuts insert plain text. Terminal sequences such as Esc or Ctrl+C depend on whether the receiving app forwards inserted control text to its terminal. The built-in SSH terminal sends terminal bytes directly.

+
+ +
+

iOS copied-image upload

+
    +
  1. Copy one image in another app.
  2. +
  3. Open the KeyJawn keyboard or built-in remote SSH terminal.
  4. +
  5. Tap the SCP key to open Upload copied image.
  6. +
  7. Select a remote SSH host that uses SSH key authentication. Password hosts remain available for terminal connections but do not appear in this upload panel.
  8. +
  9. KeyJawn prepares the copied image in memory, writes it by SFTP to the configured remote directory, and inserts the remote path.
  10. +
+

The copied-image upload feature does not request Photos or Files access on iOS. It cannot browse your photo library or local files, or list, read, or download remote files. The keyboard extension needs Full Access for this network operation. Both the app and extension require SSH key authentication for the selected upload host.

+
+ +
+

Android feature guide

+

The remaining sections describe the Android keyboard unless a section says otherwise.

@@ -99,7 +138,7 @@ import TerminalBlock from '../components/TerminalBlock.astro';

Terminal keys

The extra row above the QWERTY layout contains:

    -
  • -- sends KEYCODE_ESCAPE. Use it to exit vim insert mode, cancel Claude Code operations, dismiss prompts.
  • +
  • -- sends KEYCODE_ESCAPE. The receiving Android app decides how to handle it.
  • -- sends KEYCODE_TAB. Tab completion in shells, indentation in editors.
  • -- toggle modifier with three states:
      @@ -114,7 +153,7 @@ import TerminalBlock from '../components/TerminalBlock.astro';
    • Mic -- tap to start/stop voice input.

    Each button has a distinct background color so you can find it at a glance without reading labels.

    -

    In the full version, the three left slots (Esc, Tab, Ctrl) are customizable. Long-press any of them to open a picker and swap it for a different key. See key mapping for details.

    +

    In the Android full APK, the three left slots (Esc, Tab, Ctrl) are customizable. Long-press any of them to open a picker and select a different key. See key mapping for details.

    @@ -158,7 +197,7 @@ import TerminalBlock from '../components/TerminalBlock.astro';

    Key mapping

    -

    The full version lets you customize which keys appear in the extra row and what the quick key types. There are two ways to configure them:

    +

    The Android full APK lets you customize which keys appear in the extra row and what the quick key types. There are two ways to configure them:

    • Long-press the key -- long-press any of the three left extra row slots (Esc, Tab, Ctrl by default) or the quick key to open a picker and choose a replacement.
    • Settings -- open the menu (gear icon), tap Settings, and scroll to the key mapping section for all options in one place.
    • @@ -189,20 +228,20 @@ import TerminalBlock from '../components/TerminalBlock.astro';
    • Tap the stop button (or the mic button again) to finish.
    • Voice input is useful when you're talking to an AI agent -- most of what you type is natural language anyway.

      -

      Full version only. Requires the RECORD_AUDIO permission.

      +

      Included in the Android free and full APKs. Requires the RECORD_AUDIO permission.

    Clipboard history

    Tap the clipboard button in the extra row to open the clipboard history panel. It shows your recent copied text entries. Tap any entry to paste it at your cursor position.

    -

    Full version only.

    +

    Included in the Android free and full APKs. Clipboard pinning requires the Android full APK.

    -

    SCP upload

    -

    SCP upload lets you send photos from your phone to a remote server and automatically type the remote path at your cursor.

    +

    Android SCP upload

    +

    Android SCP upload sends a selected image to a remote server and types the remote path at your cursor.

    Setup

    1. Open KeyJawn settings (gear icon, or find it in your app drawer).
    2. @@ -216,8 +255,8 @@ import TerminalBlock from '../components/TerminalBlock.astro';
    3. KeyJawn SCPs it to the configured server.
    4. The remote file path is typed at your cursor.
    -

    This is useful for sharing screenshots with Claude Code, OpenClaw, or other AI tools that accept file paths.

    -

    Full version only. Requires INTERNET and READ_MEDIA_IMAGES permissions.

    +

    This lets you share an Android image with a remote tool that accepts file paths.

    +

    Android full APK only. Requires INTERNET and READ_MEDIA_IMAGES permissions.

    @@ -229,7 +268,7 @@ import TerminalBlock from '../components/TerminalBlock.astro';
  • Dismiss the popup (tap outside or press back) to type a literal / instead.

The command list includes common LLM CLI patterns like /help, /clear, /compact, and others.

-

Full version only.

+

Built-in command sets are included in the Android free and full APKs. Custom sets require the Android full APK.

@@ -242,7 +281,7 @@ import TerminalBlock from '../components/TerminalBlock.astro';
  • Swipe up -- switches to the symbols layer.
  • Swipe down -- switches to the letters layer.
  • -

    Full version only.

    +

    Included in the Android free and full APKs.

    @@ -262,13 +301,13 @@ import TerminalBlock from '../components/TerminalBlock.astro';
  • The new theme applies the next time the keyboard opens.
  • Themes change every visible element: key backgrounds, text colors, hint labels, the extra row, number row, and accent colors for Ctrl and Shift states.

    -

    Full version only. Free users get the Dark theme.

    +

    Android full APK only. Android free users get the Dark theme.

    Custom commands

    -

    In addition to the built-in slash command sets (Claude Code, OpenClaw, Gemini CLI, general), you can create your own command sets.

    +

    The Android full APK lets you create command sets in addition to the built-in sets.

    Creating a command set

    1. Open KeyJawn settings.
    2. @@ -289,7 +328,7 @@ import TerminalBlock from '../components/TerminalBlock.astro';
    3. Remove individual commands from custom sets by tapping the command.
    4. Custom commands appear in the slash command popup alongside the built-in ones. Only commands from enabled sets are shown.

      -

      Full version only.

      +

      Android full APK only.

    @@ -302,8 +341,8 @@ import TerminalBlock from '../components/TerminalBlock.astro';
  • Autocorrect toggles on or off for the current app.
  • When enabled, the spacebar label changes to indicate autocorrect is active.
  • -

    KeyJawn remembers the autocorrect setting per app. So you can enable it for Slack but keep it off for Termux.

    -

    Full version only.

    +

    KeyJawn remembers the autocorrect setting for each Android app.

    +

    Included in the Android free and full APKs.

    diff --git a/website/src/pages/pricing.astro b/website/src/pages/pricing.astro index 1478d1b..6f20879 100644 --- a/website/src/pages/pricing.astro +++ b/website/src/pages/pricing.astro @@ -4,21 +4,22 @@ import PricingCard from '../components/PricingCard.astro'; ---
    -

    Pricing

    -

    Free download. One-time $4 upgrade. No subscriptions, no ads, no tracking -- in any version, ever.

    +

    Pricing and availability

    +

    Android and iOS use separate distribution models. The $4 purchase applies only to the Android full APK. KeyJawn Lite has limited Google Play test tracks, but Google Play production is not active.

    +

    Android APKs

    -

    Android: APK downloads. Install by opening the file on your device or via adb install. iOS: in beta on TestFlight.

    -

    * Both versions are open source. If you can build the full APK from source, it's yours — no purchase needed. The $4 is for people who'd rather not.

    +

    Install an Android APK by opening the file on your device or by using adb install.

    +

    * The KeyJawn source is open. The $4 purchase provides a prepared Android full APK.

    +
    +
    + +
    +
    +
    +

    iOS beta

    +

    The iOS beta includes the keyboard extension and built-in remote SSH terminal. As of August 28, 2026, the public invitation is not accepting new testers. A revised App Store submission has not been sent.

    +

    The Android $4 purchase does not unlock or purchase the iOS beta.

    + View iOS status +
    @@ -72,11 +84,10 @@ import PricingCard from '../components/PricingCard.astro';
    -

    Why $4

    +

    Why the Android full APK is $4

    -

    This is a one-person project. $4 covers development costs and keeps updates coming. There are no recurring payments, no data collection, no advertising, and no investor pressure to add features that make the product worse.

    -

    The code is open source either way. The lite version is a full keyboard that works. The full version adds the features that power users want for terminal work.

    -

    The iOS version is now in beta on TestFlight. App Store pricing will be announced at launch.

    +

    This is a one-person project. $4 covers development costs and keeps updates coming. There are no recurring payments, developer analytics, or ads.

    +

    The Android code is open source either way. The free APK is a working keyboard. The full APK adds the Android features that need more permissions or configuration.

    @@ -101,6 +112,11 @@ import PricingCard from '../components/PricingCard.astro'; margin: 0 auto; } + .platform-heading { + max-width: 800px; + margin: 0 auto var(--space-md); + } + .why-section { max-width: 640px; } diff --git a/website/src/pages/privacy.astro b/website/src/pages/privacy.astro index 0b472a2..a67b139 100644 --- a/website/src/pages/privacy.astro +++ b/website/src/pages/privacy.astro @@ -4,53 +4,74 @@ import Base from '../layouts/Base.astro';

    Privacy policy

    -

    Effective date: February 15, 2026

    +

    Effective date: August 27, 2026

    Summary

    -

    KeyJawn does not collect, store, or transmit any personal data. There are no analytics, no ads, and no tracking. The app works entirely on your device.

    +

    The KeyJawn mobile apps do not send analytics, advertising data, keystrokes, or account data to the developer. There are no ads or tracking SDKs. Features that you start can send data to your configured SSH server or to the speech service provided by the operating system.

    -

    Data we do not collect

    +

    Data the mobile apps do not send to the developer

    • Keystrokes or text input
    • Usage analytics or telemetry
    • Device identifiers
    • Location data
    • -
    • Contact or account information
    • +
    • Contacts or address-book data
    +
    +

    Remote SSH terminal on iOS

    +

    The iOS terminal sends the text and terminal input that you enter to a remote SSH server that you configure. That server runs commands and returns terminal output. KeyJawn displays that output.

    +

    KeyJawn does not run shell commands on your iPhone or iPad. It does not provide a local shell, local file browser, document picker, or file provider.

    +
    +

    Voice input

    -

    Voice input uses Android's built-in SpeechRecognizer API. Audio is processed by Google's speech recognition service on your device or Google's servers, depending on your device settings. KeyJawn does not record, store, or transmit audio data itself.

    +

    On Android, voice input uses Android's SpeechRecognizer. Google or the speech service selected on the device can process the audio, based on device settings.

    +

    On iOS, voice input in the built-in SSH terminal uses Apple's speech recognition APIs. Apple can process audio according to the device's speech settings and Apple's terms. The iOS keyboard extension does not use the microphone.

    +

    KeyJawn does not keep an audio recording or send audio to a KeyJawn server.

    Clipboard history

    -

    Clipboard history is stored in memory only. It is not written to disk, not persisted between sessions, and never transmitted anywhere.

    +

    On Android, KeyJawn listens for clipboard changes while its keyboard service runs. It keeps up to 30 unpinned items in memory. The Android full APK can store up to 15 pinned items in private app preferences.

    +

    On iOS, KeyJawn reads clipboard text when you open the clipboard panel. Recent text and pins are stored in the KeyJawn App Group preferences so the app and keyboard can share them. Up to 30 recent items and 10 pins can be stored. You can remove entries or clear the history from the panel.

    +

    Clipboard text is not sent to the developer. Text that you choose in the built-in SSH terminal is sent to your configured remote server as terminal input.

    +
    + +
    +

    Remote image upload

    +

    On Android full, you can select an image and send it through SCP to your configured server.

    +

    On iOS, you copy one image before opening the upload panel. KeyJawn reads that copied image from the system pasteboard, prepares it in memory, and writes it through SFTP to the configured directory on a remote host that uses SSH key authentication. It then inserts the remote path. Password authentication remains available for terminal connections.

    +

    The iOS copied-image upload feature does not request Photos or Files access. It cannot browse local files or list, read, or download remote files.

    -

    SCP upload

    -

    SCP upload sends files directly from your device to your own SSH server using the JSch library. No third-party servers are involved. KeyJawn does not see, store, or process the file contents beyond transmitting them over SSH.

    +

    SSH settings and credentials

    +

    Android full stores saved SSH data with AndroidX EncryptedSharedPreferences.

    +

    On iOS, the SSH identity is stored in the Keychain. Host settings are stored in the app container and in the shared App Group when the keyboard needs them. Password authentication asks for the password when you connect; it is not part of the saved host record.

    +

    SSH data is used only to connect to the remote host that you select.

    -

    SSH credentials

    -

    SSH host credentials (hostname, username, port, key path) are encrypted using AES-256 via AndroidX EncryptedSharedPreferences and stored locally on your device. They never leave the device.

    +

    Keyboard permission on iOS

    +

    Basic typing and built-in shortcuts work without Full Access. Full Access is optional. The keyboard extension uses it for remote image upload and to read shared settings, user-created shortcuts, and clipboard history. KeyJawn cannot grant this system permission.

    -

    Purchases

    -

    Purchases are handled by Stripe via the KeyJawn website. The free and full versions are separate APK files -- no in-app purchase or license check is needed. No payment information is accessed or stored by the app.

    +

    Purchases and beta distribution

    +

    Stripe purchases on this website apply to the Android full APK. The KeyJawn store keeps the email address, Stripe customer reference, purchase record, download record, and support record needed to deliver and support the purchase. Stripe processes payment information. KeyJawn does not receive or store card details.

    +

    KeyJawn Lite has internal and closed Google Play test tracks. Google Play production is not active.

    +

    The iOS beta has used TestFlight. As of August 28, 2026, the public invitation is not accepting new testers. A revised App Store submission has not been sent. The Android purchase does not unlock the iOS beta.

    @@ -58,26 +79,51 @@ import Base from '../layouts/Base.astro'; - + - - + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + +
    PermissionPlatform and access Purpose When used
    RECORD_AUDIOVoice-to-text inputAndroid: RECORD_AUDIOAndroid voice input Only when you tap the mic button
    INTERNETSCP file uploadOnly during uploadAndroid full: INTERNETRemote image uploadWhen you start an upload
    Android full: READ_MEDIA_IMAGESSelect an image for uploadWhen you select an image
    iOS: microphone and speech recognitionVoice input in the built-in remote SSH terminalOnly after you tap the mic and approve the system prompts
    iOS: keyboard Full AccessOptional remote upload, shared settings, user-created shortcuts, or clipboard historyOnly if you turn it on in system Settings
    iOS: network accessRemote SSH terminal and user-started SFTP uploadWhen you connect to or upload to a configured remote host
    iOS: pasteboardClipboard panel and copied-image uploadWhen you open the clipboard or upload panel
    READ_MEDIA_IMAGESSelect photos for SCP uploadOnly when you tap the upload buttoniOS: Photos and FilesNot requestedImage upload reads one image that you copied to the pasteboard
    diff --git a/website/src/pages/support.astro b/website/src/pages/support.astro index 89f355d..27ea787 100644 --- a/website/src/pages/support.astro +++ b/website/src/pages/support.astro @@ -1,57 +1,60 @@ --- import Base from '../layouts/Base.astro'; import FAQ from '../components/FAQ.astro'; + +const faqItems = [ + { + question: 'How do I install KeyJawn?', + answer: 'Android APKs are available from GitHub releases. KeyJawn Lite also has limited Google Play test tracks, but Google Play production is not active. As of August 28, 2026, the public iOS beta invitation is not accepting new testers. A revised App Store submission has not been sent.', + }, + { + question: 'Does the iOS app run commands or browse files on my device?', + answer: 'No. The built-in terminal sends input through SSH to a remote server that you configure. The server runs the commands and KeyJawn displays the returned output. KeyJawn has no local shell or local file browser.', + }, + { + question: 'Does the iOS keyboard require Full Access?', + answer: 'Basic typing and built-in shortcuts work without Full Access. Full Access is optional for remote image upload and for reading shared keyboard settings, user-created shortcuts, and clipboard history. KeyJawn cannot grant this system permission.', + }, + { + question: 'How does image upload work on iOS?', + answer: 'Copy one image, tap the SCP key to open Upload copied image, and select a host configured for SSH key authentication. KeyJawn prepares the image in memory, writes it by SFTP to the configured remote directory, and inserts the remote path. Password authentication remains available for terminal connections. The upload feature does not request Photos or Files access or browse local or remote files.', + }, + { + question: 'Where do iOS terminal keys work?', + answer: 'Basic text and slash shortcuts work in normal text fields. The extension inserts control text through the field that has focus, so Esc, Ctrl, and arrow sequences depend on the receiving terminal app. The built-in SSH terminal sends terminal bytes directly.', + }, + { + question: 'Why do some Android keys fail in a browser terminal?', + answer: 'Some browser terminals capture JavaScript keyboard events differently from Android input methods. Native Android terminal apps usually handle the terminal key events more reliably.', + }, + { + question: 'How are SSH credentials stored?', + answer: 'Android full encrypts saved SSH data with AndroidX EncryptedSharedPreferences. On iOS, SSH identity data is stored in the Keychain and host settings are stored in the app container or shared App Group when the keyboard needs them.', + }, + { + question: 'Is this open source?', + answer: 'Yes. KeyJawn uses the MIT license, and the source is available at github.com/jamditis/keyjawn.', + }, +]; + +const faqJsonLd = { + '@context': 'https://schema.org', + '@type': 'FAQPage', + mainEntity: faqItems.map(item => ({ + '@type': 'Question', + name: item.question, + acceptedAnswer: { + '@type': 'Answer', + text: item.answer, + }, + })), +}; --- System > Languages & input > On-screen keyboard." } - }, - { - "@type": "Question", - "name": "Why don't keys work in my browser terminal?", - "acceptedAnswer": { "@type": "Answer", "text": "Some web-based terminals use JavaScript to capture keyboard events. Android's commitText method doesn't trigger the same key events as physical keypresses. Native terminal apps like Termux and JuiceSSH handle key events correctly." } - }, - { - "@type": "Question", - "name": "How do I set up SCP upload?", - "acceptedAnswer": { "@type": "Answer", "text": "Open KeyJawn settings, add a host with hostname, port, username, password, and remote upload directory. Then tap the upload button in the extra row, pick a photo, and KeyJawn handles the rest." } - }, - { - "@type": "Question", - "name": "Can I use this with Termux?", - "acceptedAnswer": { "@type": "Answer", "text": "Yes. KeyJawn works with any app that accepts keyboard input. Termux, JuiceSSH, ConnectBot, and any other terminal emulator will work." } - }, - { - "@type": "Question", - "name": "Is my SSH password stored securely?", - "acceptedAnswer": { "@type": "Answer", "text": "Yes. SSH credentials are encrypted using AndroidX EncryptedSharedPreferences with AES-256 encryption. The keys are stored in the Android Keystore, which is hardware-backed on most modern devices." } - }, - { - "@type": "Question", - "name": "Why is autocorrect off by default?", - "acceptedAnswer": { "@type": "Answer", "text": "Autocorrect uses setComposingText, which breaks web-based terminals and many SSH clients. Long-press the spacebar to enable it per app." } - }, - { - "@type": "Question", - "name": "Will there be an iOS version?", - "acceptedAnswer": { "@type": "Answer", "text": "Not planned. iOS doesn't allow third-party keyboards to send key events (like Escape or Ctrl sequences) the way Android does." } - }, - { - "@type": "Question", - "name": "Is this open source?", - "acceptedAnswer": { "@type": "Answer", "text": "Yes. MIT license. Full source at github.com/jamditis/keyjawn. Both the lite and full versions are built from the same codebase." } - } - ] - }} + jsonLd={faqJsonLd} >
    @@ -65,40 +68,7 @@ import FAQ from '../components/FAQ.astro';

    Frequently asked questions

    - GitHub releases, install it via ADB or by opening it on your phone, then enable it in Settings > System > Languages & input > On-screen keyboard. Full walkthrough in the manual." - }, - { - question: "Why don't keys work in my browser terminal?", - answer: "Some web-based terminals (like Cockpit's shell or browser SSH clients) use JavaScript to capture keyboard events. Android's commitText method doesn't trigger the same key events as physical keypresses. This is a known Android limitation -- KeyJawn sends proper key events where possible, but some WebView-based terminals only respond to commitText. Terminal emulator apps like Termux and JuiceSSH handle key events correctly." - }, - { - question: "How do I set up SCP upload?", - answer: "Open KeyJawn settings, add a host with hostname, port, username, password, and remote upload directory. Then tap the upload button in the extra row, pick a photo, and KeyJawn handles the rest. Details in the SCP upload section of the manual." - }, - { - question: "Can I use this with Termux?", - answer: "Yes. KeyJawn works with any app that accepts keyboard input. Termux, JuiceSSH, ConnectBot, and any other terminal emulator will work. The terminal key row (Esc, Tab, Ctrl, arrows) is designed for exactly this." - }, - { - question: "Is my SSH password stored securely?", - answer: "Yes. SSH credentials are encrypted using AndroidX EncryptedSharedPreferences with AES-256 encryption. The keys are stored in the Android Keystore, which is hardware-backed on most modern devices." - }, - { - question: "Why is autocorrect off by default?", - answer: "Autocorrect uses setComposingText, which breaks web-based terminals and many SSH clients. In a shell, autocorrect turning git into got is worse than having no autocorrect at all. Long-press the spacebar to enable it per app -- turn it on for Slack, keep it off for Termux." - }, - { - question: "Will there be an iOS version?", - answer: "Not planned. iOS doesn't allow third-party keyboards to send key events (like Escape or Ctrl sequences) the way Android does. The level of system integration KeyJawn needs isn't possible on iOS." - }, - { - question: "Is this open source?", - answer: "Yes. MIT license. Full source at github.com/jamditis/keyjawn. Both the lite and full versions are built from the same codebase." - } - ]} /> +
    @@ -110,7 +80,7 @@ import FAQ from '../components/FAQ.astro';

    Found a problem? File an issue on GitHub with the following info:

      -
    • Your device model and Android version
    • +
    • Your device model, platform, and operating system version
    • The app you were typing in (Termux, Chrome, Slack, etc.)
    • What happened vs. what you expected
    • Steps to reproduce, if possible
    • @@ -128,20 +98,28 @@ import FAQ from '../components/FAQ.astro';

      Known limitations

      -

      No swipe-to-type

      -

      Gesture typing (sliding your finger across keys to spell words) requires Google's proprietary gesture decoder library. KeyJawn doesn't include it because it's closed-source and would add significant APK size. Swipe gestures in KeyJawn are for actions (delete word, space, layer switch), not word input.

      +

      No Android swipe-to-type

      +

      The Android keyboard does not include a gesture decoder. Its swipe gestures run actions such as delete word, space, and layer changes. They do not enter words.

      -

      Basic autocorrect only

      -

      KeyJawn's autocorrect handles simple corrections but doesn't include word prediction, next-word suggestions, or a suggestions bar. This is intentional -- the keyboard is built for terminal use where autocorrect is usually harmful.

      +

      Basic Android autocorrect only

      +

      The Android keyboard handles simple corrections. It does not include word prediction, next-word suggestions, or a suggestions bar.

      -

      SCP upload is full version only

      -

      SCP upload requires the JSch library and network permissions. To keep the lite version permission-free, SCP is exclusive to the full version.

      +

      Android SCP upload is full only

      +

      Android gallery upload requires the JSch library and network permissions, so it is limited to the Android full APK.

      WebView terminal compatibility

      -

      Some browser-based terminals don't respond to Android key events the way native terminal apps do. If keys like Esc or Tab don't work in a web terminal, the issue is usually on the web app's side, not KeyJawn's. Native terminal apps (Termux, JuiceSSH) work reliably.

      +

      Some browser-based terminals do not handle Android key events like native terminal apps. Esc, Tab, and other terminal keys can behave differently in each receiving app.

      +
      +
      +

      iOS extension key limits

      +

      The iOS extension cannot send hardware key events. It inserts text and control sequences through the focused field. The receiving app decides whether to forward those sequences to a terminal.

      +
      +
      +

      iOS copied-image upload only

      +

      The iOS app and keyboard read one image that you copy first and upload it only to a host configured for SSH key authentication. They do not open Photos or Files, browse remote files, or download files from the server.

    diff --git a/website/src/pages/thanks.astro b/website/src/pages/thanks.astro index 00ed804..d1b1307 100644 --- a/website/src/pages/thanks.astro +++ b/website/src/pages/thanks.astro @@ -3,19 +3,19 @@ import Base from '../layouts/Base.astro'; ---

    Thanks for your purchase

    -

    Your purchase is confirmed.

    -

    Check your email for a download link to the full version APK. If it doesn't arrive in a few minutes, check your spam folder or contact jamditis@gmail.com.

    +

    Your KeyJawn for Android purchase is confirmed.

    +

    Check your email for the Android full APK download link. If it does not arrive in a few minutes, check your spam folder or contact jamditis@gmail.com.

    -

    Install steps

    +

    Android install steps

    1. Download the APK from the link in your email
    2. Open the file on your Android device (or use adb install keyjawn-full.apk)
    3. @@ -26,7 +26,7 @@ import Base from '../layouts/Base.astro';
    diff --git a/worker/tests/test_content.py b/worker/tests/test_content.py index 3f299f1..3082d9d 100644 --- a/worker/tests/test_content.py +++ b/worker/tests/test_content.py @@ -1,5 +1,7 @@ """Tests for the content generation module.""" +from pathlib import Path + from worker.content import ( ContentRequest, PLATFORM_LIMITS, @@ -29,6 +31,44 @@ def test_build_prompt_original_post(): assert "KeyJawn" in prompt +def test_build_prompt_names_ios_upload_and_input_boundaries(): + req = ContentRequest( + pillar="demo", + platform="twitter", + topic="iOS keyboard and copied-image upload", + ) + + prompt = build_generation_prompt(req) + + assert "configured key-authenticated remote host" in prompt + assert "receiving app decides how to interpret" in prompt + assert "user-created shortcuts" in prompt + assert "Never describe iOS extension controls as hardware key events" in prompt + + +def test_build_prompt_records_waiting_for_review_without_claiming_approval(): + req = ContentRequest( + pillar="release", + platform="twitter", + topic="iOS release state", + ) + + prompt = build_generation_prompt(req) + + assert "fresh signed build 9 archive" in prompt + assert "needs a fresh archive" not in prompt + assert "uploaded" in prompt + assert "valid" in prompt + assert "selected for version 1.0" in prompt + assert "manual release" in prompt + assert "waiting for review" in prompt + assert "not approved" in prompt + assert "not publicly released" in prompt + assert "build 9 is not selected" not in prompt + assert "not uploaded" not in prompt + assert "did not archive" not in prompt + + def test_build_prompt_reply(): req = ContentRequest( pillar="engagement", @@ -145,3 +185,25 @@ def test_build_prompt_includes_utm_url(): prompt = build_generation_prompt(req) assert "utm_source=twitter" in prompt assert "utm_medium=social" in prompt + + +def test_build_prompt_limits_google_play_claims(): + req = ContentRequest( + pillar="release", + platform="twitter", + topic="Android availability", + ) + + prompt = build_generation_prompt(req) + + assert "internal and closed Google Play test tracks" in prompt + assert "Google Play production is inactive" in prompt + assert "Never claim public Google Play availability" in prompt + + +def test_generated_worker_packaging_outputs_are_ignored(): + repository_root = Path(__file__).resolve().parents[2] + ignore_rules = (repository_root / ".gitignore").read_text().splitlines() + + assert "worker/build/" in ignore_rules + assert "worker/*.egg-info/" in ignore_rules diff --git a/worker/worker/calendar_gen.py b/worker/worker/calendar_gen.py index 0a9aaac..ee6ab1e 100644 --- a/worker/worker/calendar_gen.py +++ b/worker/worker/calendar_gen.py @@ -11,21 +11,21 @@ TOPICS = { "awareness": [ - "Standard keyboards failing at CLI tasks", - "Autocorrect mangling shell commands", - "Missing Esc/Tab/Ctrl on mobile", - "The pain of arrow keys on touch keyboards", - "Mobile keyboards that actually behave like real keyboards (auto-cap, double-space period, haptic feedback)", - "SSH from iPhone: what it actually takes to do it right", + "Android: standard keyboards failing at CLI tasks", + "Android: autocorrect mangling shell commands", + "Android: missing Esc, Tab, and Ctrl on mobile", + "Android: the pain of arrow keys on touch keyboards", + "Android: auto-capitalization, double-space period, and key-specific haptics", + "iOS: terminal commands execute on a configured remote SSH server", ], "demo": [ - "Voice input composing a Claude Code prompt", - "Terminal key row in action", - "SCP image upload mid-conversation", - "Slash command shortcuts demo", - "Spacebar cursor movement — hold and drag to reposition without lifting", - "Adaptive Enter key switching between Go/Send/Search/Done by context", - "iOS version in development — what we're building (SSH terminal + keyboard extension)", + "Android: voice input composing a CLI prompt", + "Android: terminal key row in action", + "Android full: SCP image upload during a remote session", + "Android: slash command shortcuts", + "Android: hold and drag the spacebar to move the cursor", + "Android: adaptive Enter key for Go, Send, Search, Next, and Done", + "iOS: remote SSH terminal and companion keyboard in a supported text field", ], "engagement": [ "Reply to mobile SSH conversations", @@ -36,13 +36,14 @@ "Download/purchase milestone update", "Share user feedback or support resolution", "GitHub activity update", - "iOS version in private beta testing — progress update", + "iOS App Store status: review build 2 rejected; no revised upload or resubmission", ], "behind_scenes": [ "What Joe is working on next", "Open source philosophy and $4 pricing", "Dev update on upcoming features", - "How the iOS version works around Apple's keyboard restrictions", + "How iOS keeps remote command execution separate from the device", + "Where iOS supports third-party keyboards and where it uses the system keyboard", ], } diff --git a/worker/worker/content.py b/worker/worker/content.py index 7ff1bcb..a69fcc2 100644 --- a/worker/worker/content.py +++ b/worker/worker/content.py @@ -85,15 +85,30 @@ def build_generation_prompt(req: ContentRequest) -> str: lines.append( "About KeyJawn: keyboard app for CLI/LLM agents. " - "Android: permanent Esc/Tab/Ctrl/arrow row, voice input, SCP image upload, slash commands, " - "$4 one-time purchase, free lite version. " - "Recent Android UX additions: backspace acceleration (two-stage, faster the longer you hold), " + "Android-only: permanent Esc/Tab/Ctrl/arrow row, voice input, slash commands, " + "and a free lite version. The $4 Android full version adds SCP image upload. " + "KeyJawn Lite has active internal and closed Google Play test tracks, but Google Play " + "production is inactive. Never claim public Google Play availability. " + "Other Android UX features: backspace acceleration (two-stage, faster the longer you hold), " "spacebar cursor movement (hold spacebar + drag horizontally to reposition cursor), " "adaptive Enter key (label and action change to Go/Send/Search/Next/Done based on context), " "double-tap space inserts period+space, auto-capitalize after sentence-ending punctuation, " "differentiated haptic feedback per key type (Enter/Shift/Ctrl/swipe each feel different). " - "iOS: actively in development, currently in private beta testing (not yet publicly available). " - "Building a SwiftUI app with a built-in SSH terminal and keyboard extension. App Store launch TBD. " + "iOS-only: a remote SSH terminal and companion custom keyboard. Commands execute on the " + "user-configured remote host, not on iOS. The app has no local shell or device-file browser. " + "Copied-image upload writes one pasteboard image to a configured key-authenticated remote host through SFTP; " + "that upload feature does not browse local files or list, read, or download remote files. " + "The keyboard works only in apps and text fields that " + "support third-party keyboards. It inserts text or control sequences, and the receiving app decides " + "how to interpret them. The built-in SSH terminal sends terminal bytes directly. " + "Basic typing works without Allow Full Access. " + "Full Access is optional and is used only for copied-image upload, shared keyboard " + "settings, user-created shortcuts, or clipboard history. " + "Apple rejected App Store review build 2 under guideline 2.5.2. A fresh signed build 9 archive " + "was created and verified from the corrected source on August 31, 2026. The exact archive was " + "uploaded, and Apple processed build 9 as valid. Build 9 is selected for version 1.0, " + "manual release is enabled, and the submission is waiting for review. The app is not approved " + "and not publicly released. Do not claim App Store approval, public availability, or a launch date. " f"Link: {tracked_url}" ) @@ -103,6 +118,10 @@ def build_generation_prompt(req: ContentRequest) -> str: "No rhetorical questions. No hashtag spam. No exclamation marks. " "No emoji strings. No hype words. No filler. No fake emotion. " "Never trash competitors. Put links at the end if needed. " + "Name the platform for platform-specific features. Never say that the iOS keyboard works in " + "any app, every app, or secure and phone-pad fields. Never say that KeyJawn executes code on " + "iOS or browses device files. Never claim that iOS copied-image upload works with password authentication. " + "Never describe iOS extension controls as hardware key events. Never describe an Android-only feature as an iOS feature. " f"If you include the link, use EXACTLY: {tracked_url}" )